Skip to content

Commit 32603fa

Browse files
authored
Merge pull request #45 from seporaitis/v4_scheme
AWS V4 signature scheme
2 parents 99eaa4f + 9338df0 commit 32603fa

File tree

6 files changed

+264
-105
lines changed

6 files changed

+264
-105
lines changed

CHANGELOG.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1+
## 1.1.0 (2016-07-11)
2+
- #32: Add support for AWS v4 signature (@mbrossard)
3+
- #32: Add support for s3:// scheme (@asedge, @mbrossard)
4+
- #43: Add retries with exponential back-off (@bemehow, @mbrossard)
5+
16
## 1.0.3 (2016-07-05)
2-
- Add support for delegated roles (@ToneD)
7+
- #44: Add support for delegated roles (@ToneD)
38

49
## 1.0.2 (2015-11-03)
5-
- Fix signature issue with python 2.7 (@mbrossard)
10+
- #34: Fix signature issue with python 2.7 (@mbrossard)

Makefile

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
NAME = yum-plugin-s3-iam
2-
VERSION = 1.0.3
3-
RELEASE = 1
4-
ARCH = noarch
1+
NAME = yum-plugin-s3-iam
2+
VERSION = 1.1.0
3+
RELEASE = 1
4+
ARCH = noarch
55

66
RPM_TOPDIR ?= $(shell rpm --eval '%{_topdir}')
77

README.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,6 @@ use this plugin:
3333
Currently the plugin does not support:
3434
- Proxy server configuration
3535
- Multi-valued baseurl or mirrorlist
36-
- AWS version 4 signatures needed by S3 in some regions (see v4_scheme
37-
branch for work in progress)
3836

3937
## Testing
4038

s3iam.py

Lines changed: 196 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,12 @@
1515

1616
import urllib2
1717
import urlparse
18+
import datetime
1819
import time
1920
import hashlib
2021
import hmac
2122
import json
23+
import re
2224

2325
import yum
2426
import yum.config
@@ -31,7 +33,7 @@
3133
__email__ = "julius@seporaitis.net"
3234
__copyright__ = "Copyright 2012, Julius Seporaitis"
3335
__license__ = "Apache 2.0"
34-
__version__ = "1.0.3"
36+
__version__ = "1.1.0"
3537

3638

3739
__all__ = ['requires_api_version', 'plugin_type', 'CONDUIT',
@@ -40,53 +42,115 @@
4042
requires_api_version = '2.5'
4143
plugin_type = yum.plugins.TYPE_CORE
4244
CONDUIT = None
45+
DEFAULT_DELAY = 3
46+
DEFAULT_BACKOFF = 2
47+
BUFFER_SIZE = 1024 * 1024
48+
OPTIONAL_ATTRIBUTES = ['priority', 'base_persistdir', 'metadata_expire',
49+
'skip_if_unavailable', 'keepcache', 'priority']
50+
UNSUPPORTED_ATTRIBUTES = ['mirrorlist', 'proxy']
4351

4452

4553
def config_hook(conduit):
4654
yum.config.RepoConf.s3_enabled = yum.config.BoolOption(False)
55+
yum.config.RepoConf.region = yum.config.Option()
4756
yum.config.RepoConf.key_id = yum.config.Option()
4857
yum.config.RepoConf.secret_key = yum.config.Option()
4958
yum.config.RepoConf.delegated_role = yum.config.Option()
59+
yum.config.RepoConf.baseurl = yum.config.UrlListOption(
60+
schemes=('http', 'https', 's3', 'ftp', 'file')
61+
)
62+
yum.config.RepoConf.backoff = yum.config.Option()
63+
yum.config.RepoConf.delay = yum.config.Option()
64+
65+
66+
def parse_url(url):
67+
# http://docs.aws.amazon.com/AmazonS3/latest/dev/UsingBucket.html
68+
url = url[0] if isinstance(url, list) else url
69+
70+
# http[s]://<bucket>.s3.amazonaws.com
71+
m = re.match(r'(http|https|s3)://([a-z0-9][a-z0-9-.]{1,61}[a-z0-9])[.]s3[.]amazonaws[.]com(.*)$', url)
72+
if m:
73+
return (m.group(2), None, m.group(3))
74+
75+
# http[s]://<bucket>.s3-<aws-region>.amazonaws.com
76+
m = re.match(r'(http|https|s3)://([a-z0-9][a-z0-9-.]{1,61}[a-z0-9])[.]s3-([a-z0-9-]+)[.]amazonaws[.]com(.*)$', url)
77+
if m:
78+
return (m.group(2), m.group(3), m.group(4))
79+
80+
# http[s]://s3.amazonaws.com/<bucket>
81+
m = re.match(r'(http|https|s3)://s3[.]amazonaws[.]com/([a-z0-9][a-z0-9-.]{1,61}[a-z0-9])(.*)$', url)
82+
if m:
83+
return (m.group(2), 'us-east-1', m.group(3))
84+
85+
# http[s]://s3-<region>.amazonaws.com/<bucket>
86+
m = re.match(r'(http|https|s3)://s3-([a-z0-9-]+)[.]amazonaws[.]com/([a-z0-9][a-z0-9-.]{1,61}[a-z0-9])(.*)$', url)
87+
if m:
88+
return (m.group(3), m.group(2), m.group(4))
89+
90+
return (None, None, None)
91+
92+
93+
def replace_repo(repos, repo):
94+
repos.delete(repo.id)
95+
repos.add(S3Repository(repo.id, repo))
5096

5197

5298
def prereposetup_hook(conduit):
5399
"""Plugin initialization hook. Setup the S3 repositories."""
54100

55101
repos = conduit.getRepos()
56-
57102
for repo in repos.listEnabled():
103+
url = repo.baseurl
104+
if(isinstance(url, list)):
105+
if len(url) == 0:
106+
continue
107+
url = url[0]
108+
if re.match(r'^s3://', url):
109+
repo.s3_enabled = 1
58110
if isinstance(repo, YumRepository) and repo.s3_enabled:
59-
new_repo = S3Repository(repo.id, repo.baseurl)
60-
new_repo.name = repo.name
61-
# new_repo.baseurl = repo.baseurl
62-
new_repo.mirrorlist = repo.mirrorlist
63-
new_repo.basecachedir = repo.basecachedir
64-
new_repo.gpgcheck = repo.gpgcheck
65-
new_repo.gpgkey = repo.gpgkey
66-
new_repo.key_id = repo.key_id
67-
new_repo.secret_key = repo.secret_key
68-
new_repo.proxy = repo.proxy
69-
new_repo.enablegroups = repo.enablegroups
70-
if hasattr(repo, 'priority'):
71-
new_repo.priority = repo.priority
72-
if hasattr(repo, 'base_persistdir'):
73-
new_repo.base_persistdir = repo.base_persistdir
74-
if hasattr(repo, 'metadata_expire'):
75-
new_repo.metadata_expire = repo.metadata_expire
76-
if hasattr(repo, 'skip_if_unavailable'):
77-
new_repo.skip_if_unavailable = repo.skip_if_unavailable
78-
79-
repos.delete(repo.id)
80-
repos.add(new_repo)
111+
replace_repo(repos, repo)
81112

82113

83114
class S3Repository(YumRepository):
84115
"""Repository object for Amazon S3, using IAM Roles."""
85116

86-
def __init__(self, repoid, baseurl):
117+
def __init__(self, repoid, repo):
87118
super(S3Repository, self).__init__(repoid)
119+
120+
bucket, region, path = parse_url(repo.baseurl)
121+
122+
if bucket is None:
123+
msg = "s3iam: unable to parse url %s'" % repo.baseurl
124+
raise yum.plugins.PluginYumExit(msg)
125+
126+
if region:
127+
self.baseurl = "https://s3-%s.amazonaws.com/%s%s" % (region, bucket, path)
128+
else:
129+
self.baseurl = "https://%s.s3.amazonaws.com%s" % (bucket, path)
130+
131+
self.name = repo.name
132+
self.region = repo.region if repo.region else region
133+
self.basecachedir = repo.basecachedir
134+
self.gpgcheck = repo.gpgcheck
135+
self.gpgkey = repo.gpgkey
136+
self.key_id = repo.key_id
137+
self.secret_key = repo.secret_key
138+
self.enablegroups = repo.enablegroups
139+
140+
self.retries = repo.retries
141+
self.backoff = repo.backoff
142+
self.delay = repo.delay
143+
144+
for attr in OPTIONAL_ATTRIBUTES:
145+
if hasattr(repo, attr):
146+
setattr(self, attr, getattr(repo, attr))
147+
148+
for attr in UNSUPPORTED_ATTRIBUTES:
149+
if getattr(repo, attr):
150+
msg = "%s: Unsupported attribute: %s." % (__file__, attr)
151+
raise yum.plugins.PluginYumExit(msg)
152+
88153
self.iamrole = None
89-
self.baseurl = baseurl
90154
self.grabber = None
91155
self.enable()
92156

@@ -118,11 +182,17 @@ def __init__(self, repo):
118182
"""
119183
if isinstance(repo, basestring):
120184
self.baseurl = repo
185+
self.region = None
186+
self.retries = 0
121187
else:
188+
self.region = repo.region
189+
self.retries = repo.retries
190+
self.backoff = DEFAULT_BACKOFF if repo.backoff is None else repo.backoff
191+
self.delay = DEFAULT_DELAY if repo.delay is None else repo.delay
122192
if len(repo.baseurl) != 1:
123-
raise yum.plugins.PluginYumExit("s3iam: repository '%s' "
124-
"must have only one "
125-
"'baseurl' value" % repo.id)
193+
msg = "%s: repository '%s' must" % (__file__, repo.id)
194+
msg += 'have only one baseurl value'
195+
raise yum.plugins.PluginYumExit(msg)
126196
else:
127197
self.baseurl = repo.baseurl[0]
128198
# Ensure urljoin doesn't ignore base path:
@@ -206,15 +276,13 @@ def get_instance_region(self):
206276
response.close()
207277
self.region = data[:-1]
208278

209-
def _request(self, path):
279+
def _request(self, path, timeval=None):
210280
url = urlparse.urljoin(self.baseurl, urllib2.quote(path))
211281
request = urllib2.Request(url)
212-
if self.token:
213-
request.add_header('x-amz-security-token', self.token)
214-
signature = self.sign(request)
215-
request.add_header('Authorization', "AWS {0}:{1}".format(
216-
self.access_key,
217-
signature))
282+
if self.region:
283+
self.signV4(request, timeval)
284+
else:
285+
self.signV2(request, timeval)
218286
return request
219287

220288
def urlgrab(self, url, filename=None, **kwargs):
@@ -226,26 +294,38 @@ def urlgrab(self, url, filename=None, **kwargs):
226294
filename = filename[1:]
227295

228296
response = None
229-
try:
230-
out = open(filename, 'w+')
231-
response = urllib2.urlopen(request)
232-
buff = response.read(8192)
233-
while buff:
234-
out.write(buff)
235-
buff = response.read(8192)
236-
except urllib2.HTTPError, e:
237-
# Wrap exception as URLGrabError so that YumRepository catches it
238-
from urlgrabber.grabber import URLGrabError
239-
new_e = URLGrabError(14, '%s on %s' % (e, url))
240-
new_e.code = e.code
241-
new_e.exception = e
242-
new_e.url = url
243-
raise new_e
244-
finally:
245-
if response:
246-
response.close()
247-
out.close()
248-
297+
retries = self.retries
298+
delay = self.delay
299+
out = open(filename, 'w+')
300+
while retries > 0:
301+
try:
302+
response = urllib2.urlopen(request)
303+
buff = response.read(BUFFER_SIZE)
304+
while buff:
305+
out.write(buff)
306+
buff = response.read(BUFFER_SIZE)
307+
except urllib2.HTTPError, e:
308+
if retries > 0:
309+
time.sleep(delay)
310+
delay *= self.backoff
311+
else:
312+
# Wrap exception as URLGrabError so that YumRepository catches it
313+
from urlgrabber.grabber import URLGrabError
314+
msg = '%s on %s tried' % (e, url)
315+
if self.retries > 0:
316+
msg += ' tried %d time(s)' % (self.retries)
317+
new_e = URLGrabError(14, msg)
318+
new_e.code = e.code
319+
new_e.exception = e
320+
new_e.url = url
321+
raise new_e
322+
finally:
323+
retries -= 1
324+
if response:
325+
response.close()
326+
break
327+
328+
out.close()
249329
return filename
250330

251331
def urlopen(self, url, **kwargs):
@@ -256,30 +336,19 @@ def urlread(self, url, limit=None, **kwargs):
256336
"""urlread(url) return the contents of the file as a string."""
257337
return urllib2.urlopen(self._request(url)).read()
258338

259-
def sign(self, request, timeval=None):
339+
def signV2(self, request, timeval=None):
260340
"""Attach a valid S3 signature to request.
261341
request - instance of Request
262342
"""
263-
date = time.strftime("%a, %d %b %Y %H:%M:%S GMT", timeval or time.gmtime())
343+
t = timeval or time.gmtime()
344+
date = time.strftime("%a, %d %b %Y %H:%M:%S +0000", t)
264345
request.add_header('Date', date)
265-
host = request.get_host()
266346

267-
# TODO: bucket name finding is ugly, I should find a way to support
268-
# both naming conventions: http://bucket.s3.amazonaws.com/ and
269-
# http://s3.amazonaws.com/bucket/
270-
try:
271-
pos = host.find(".s3")
272-
assert pos != -1
273-
bucket = host[:pos]
274-
except AssertionError:
275-
raise yum.plugins.PluginYumExit(
276-
"s3iam: baseurl hostname should be in format: "
277-
"'<bucket>.s3<aws-region>.amazonaws.com'; "
278-
"found '%s'" % host)
279-
280-
resource = "/%s%s" % (bucket, request.get_selector(), )
347+
(bucket, ignore, path) = parse_url(request.get_full_url())
348+
resource = '/' + bucket + path
281349
if self.token:
282350
amz_headers = 'x-amz-security-token:%s\n' % self.token
351+
request.add_header('x-amz-security-token', self.token)
283352
else:
284353
amz_headers = ''
285354
sigstring = ("%(method)s\n\n\n%(date)s\n"
@@ -292,5 +361,57 @@ def sign(self, request, timeval=None):
292361
str(self.secret_key),
293362
str(sigstring),
294363
hashlib.sha1).digest()
295-
signature = digest.encode('base64')
296-
return signature.strip()
364+
signature = digest.encode('base64').rstrip()
365+
366+
authorization = "AWS {0}:{1}".format(self.access_key, signature)
367+
request.add_header('Authorization', authorization)
368+
369+
def derive(self, key, msg):
370+
return hmac.new(key, msg.encode('utf-8'), hashlib.sha256).digest()
371+
372+
def deriveKey(self, key, date, region, service):
373+
kDate = self.derive(('AWS4' + key).encode('utf-8'), date)
374+
kRegion = self.derive(kDate, region)
375+
kService = self.derive(kRegion, service)
376+
return self.derive(kService, 'aws4_request')
377+
378+
def signV4(self, request, timeval=None):
379+
algorithm = 'AWS4-HMAC-SHA256'
380+
t = datetime.datetime.utcnow()
381+
382+
amzdate = t.strftime('%Y%m%dT%H%M%SZ')
383+
amz_headers = ('host:%s\nx-amz-date:%s\n' %
384+
(request.get_host(), amzdate))
385+
signed_headers = 'host;x-amz-date'
386+
if self.token:
387+
amz_headers += 'x-amz-security-token:%s\n' % self.token
388+
signed_headers += ';x-amz-security-token'
389+
request.add_header('x-amz-security-token', self.token)
390+
391+
# Hash request
392+
content_h = hashlib.sha256('').hexdigest() # Empty content
393+
req = ('GET\n%s\n\n%s\n%s\n%s' %
394+
(request.get_selector(), amz_headers, signed_headers, content_h))
395+
req_hash = hashlib.sha256(req).hexdigest()
396+
397+
# Assemble content to be signed
398+
datestamp = t.strftime('%Y%m%d')
399+
scope = datestamp + '/' + self.region + '/s3/aws4_request'
400+
sign_content = '%s\n%s\n%s\n%s' % (algorithm, amzdate, scope, req_hash)
401+
402+
# Get derived key
403+
signing_key = self.deriveKey(self.secret_key, datestamp,
404+
self.region, 's3')
405+
406+
# Compute signature
407+
signature = hmac.new(signing_key, (sign_content).encode('utf-8'),
408+
hashlib.sha256).hexdigest()
409+
410+
# Assemble 'Authorization' header value
411+
credential = self.access_key + '/' + scope
412+
auth = (('%s Credential=%s, SignedHeaders=%s, Signature=%s') %
413+
(algorithm, credential, signed_headers, signature))
414+
415+
request.add_header('x-amz-content-sha256', content_h)
416+
request.add_header('x-amz-date', amzdate)
417+
request.add_header('Authorization', auth)

0 commit comments

Comments
 (0)