File Coverage

lib/AWS/Signature4.pm
Criterion Covered Total %
statement 164 174 94.2
branch 23 42 54.7
condition 18 50 36.0
subroutine 24 24 100.0
pod 4 6 66.6
total 233 296 78.7


line stmt bran cond sub pod time code
1             package AWS::Signature4;
2              
3 1     1   554 use strict;
  1         1  
  1         35  
4 1     1   480 use POSIX 'strftime';
  1         4667  
  1         5  
5 1     1   1394 use URI;
  1         3362  
  1         34  
6 1     1   494 use URI::QueryParam;
  1         499  
  1         23  
7 1     1   5 use URI::Escape;
  1         1  
  1         65  
8 1     1   523 use Digest::SHA 'sha256_hex','hmac_sha256','hmac_sha256_hex';
  1         2824  
  1         72  
9 1     1   533 use Date::Parse;
  1         5297  
  1         113  
10 1     1   5 use Carp 'croak';
  1         1  
  1         2940  
11              
12             our $VERSION = '1.02';
13              
14             =head1 NAME
15              
16             AWS::Signature4 - Create a version4 signature for Amazon Web Services
17              
18             =head1 SYNOPSIS
19              
20             use AWS::Signature4;
21             use HTTP::Request::Common;
22             use LWP;
23              
24             my $signer = AWS::Signature4->new(-access_key => 'AKIDEXAMPLE',
25             -secret_key => 'wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY');
26             my $ua = LWP::UserAgent->new();
27              
28             # Example POST request
29             my $request = POST('https://iam.amazonaws.com',
30             [Action=>'ListUsers',
31             Version=>'2010-05-08']);
32             $signer->sign($request);
33             my $response = $ua->request($request);
34              
35             # Example GET request
36             my $uri = URI->new('https://iam.amazonaws.com');
37             $uri->query_form(Action=>'ListUsers',
38             Version=>'2010-05-08');
39              
40             my $url = $signer->signed_url($uri); # This gives a signed URL that can be fetched by a browser
41             my $response = $ua->get($url);
42              
43             =head1 DESCRIPTION
44              
45             This module implement's Amazon Web Service's Signature version 4
46             (http://docs.aws.amazon.com/general/latest/gr/signature-version-4.html).
47              
48             =head1 METHODS
49              
50             =over 4
51              
52             =item $signer = AWS::Signature4->new(-access_key => $account_id,-secret_key => $private_key);
53              
54             Create a signing object using your AWS account ID and secret key. You
55             may also use the temporary security tokens received from Amazon's STS
56             service, either by passing the access and secret keys derived from the
57             token, or by passing a VM::EC2::Security::Token produced by the
58             VM::EC2 module.
59              
60             Arguments:
61              
62             Argument name Argument Value
63             ------------- --------------
64             -access_key An AWS acccess key (account ID)
65              
66             -secret_key An AWS secret key
67              
68             -security_token A VM::EC2::Security::Token object
69              
70              
71             If a security token is provided, it overrides any values given for
72             -access_key or -secret_key.
73              
74             If the environment variables EC2_ACCESS_KEY and/or EC2_SECRET_KEY are
75             set, their contents are used as defaults for -acccess_key and
76             -secret_key.
77              
78             =cut
79              
80             sub new {
81 1     1 1 14887 my $self = shift;
82 1         4 my %args = @_;
83              
84 1         2 my ($id,$secret,$token);
85 1 50 33     6 if (ref $args{-security_token} && $args{-security_token}->can('access_key_id')) {
86 0         0 $id = $args{-security_token}->accessKeyId;
87 0         0 $secret = $args{-security_token}->secretAccessKey;
88             }
89              
90 1 50 33     11 $id ||= $args{-access_key} || $ENV{EC2_ACCESS_KEY}
      33        
91             or croak "Please provide -access_key parameter or define environment variable EC2_ACCESS_KEY";
92 1 50 33     9 $secret ||= $args{-secret_key} || $ENV{EC2_SECRET_KEY}
      33        
93             or croak "Please provide -secret_key or define environment variable EC2_SECRET_KEY";
94              
95 1 50 33     12 return bless {
96             access_key => $id,
97             secret_key => $secret,
98             (defined($args{-security_token}) ? (security_token => $args{-security_token}) : ()),
99             },ref $self || $self;
100             }
101              
102 7     7 0 15 sub access_key { shift->{access_key } }
103 4     4 0 7 sub secret_key { shift->{secret_key } }
104              
105             =item $signer->sign($request [,$region] [,$payload_sha256_hex])
106              
107             Given an HTTP::Request object, add the headers required by AWS and
108             then sign it with a version 4 signature by adding an "Authorization"
109             header.
110              
111             The request must include a URL from which the AWS endpoint and service
112             can be derived, such as "ec2.us-east-1.amazonaws.com." In some cases
113             (e.g. S3 bucket operations) the endpoint does not indicate the
114             region. In this case, the region can be forced by passing a defined
115             value for $region. The current date and time will be added to the
116             request using an "X-Amz-Date header." To force the date and time to a
117             fixed value, include the "Date" header in the request.
118              
119             The request content, or "payload" is retrieved from the HTTP::Request
120             object by calling its content() method.. Under some circumstances the
121             payload is not included directly in the request, but is in an external
122             file that will be uploaded as the request is executed. In this case,
123             you must pass a second argument containing the results of running
124             sha256_hex() (from the Digest::SHA module) on the content.
125              
126             The method returns a true value if successful. On errors, it will
127             throw an exception.
128              
129             =item $url = $signer->signed_url($request)
130              
131             This method will generate a signed GET URL for the request. The URL
132             will include everything needed to perform the request.
133              
134             =back
135              
136             =cut
137              
138             sub sign {
139 1     1 1 7547 my $self = shift;
140 1         3 my ($request,$region,$payload_sha256_hex) = @_;
141 1         15 $self->_add_date_header($request);
142 1         41 $self->_sign($request,$region,$payload_sha256_hex);
143             }
144              
145             =item my $url $signer->signed_url($request_or_uri [,$expires])
146              
147             Pass an HTTP::Request, a URI object, or just a plain URL string
148             containing the proper endpoint and parameters needed for an AWS REST
149             API Call. This method will return an appropriately signed request as a
150             URI object, which can be shared with non-AWS users for the purpose of,
151             e.g., accessing an object in a private S3 bucket.
152              
153             Pass an optional $expires argument to indicate that the URL will only
154             be valid for a finite period of time. The value of the argument is in
155             seconds.
156              
157             =cut
158              
159              
160             sub signed_url {
161 3     3 1 1828 my $self = shift;
162 3         4 my ($arg1,$expires) = @_;
163            
164 3         3 my ($request,$uri);
165              
166 3 100 66     15 if (ref $arg1 && UNIVERSAL::isa($arg1,'HTTP::Request')) {
167 1         2 $request = $arg1;
168 1         3 $uri = $request->uri;
169 1         7 my $content = $request->content;
170 1 50       12 $uri->query($content) if $content;
171 1 50 33     5 if (my $date = $request->header('X-Amz-Date') || $request->header('Date')) {
172 1         130 $uri->query_param('Date'=>$date);
173             }
174             }
175              
176 3   66     182 $uri ||= URI->new($arg1);
177 3   33     125 my $date = $uri->query_param_delete('Date') || $uri->query_param_delete('X-Amz-Date');
178 3         358 $request = HTTP::Request->new(GET=>$uri);
179 3         131 $request->header('Date'=> $date);
180 3         103 $uri = $request->uri; # because HTTP::Request->new() copies the uri!
181              
182 3 50       17 return $uri if $uri->query_param('X-Amz-Signature');
183              
184              
185 3         139 my $scope = $self->_scope($request);
186              
187 3         8 $uri->query_param('X-Amz-Algorithm' => $self->_algorithm);
188 3         362 $uri->query_param('X-Amz-Credential' => $self->access_key . '/' . $scope);
189 3         446 $uri->query_param('X-Amz-Date' => $self->_datetime($request));
190 3 50       569 $uri->query_param('X-Amz-Expires' => $expires) if $expires;
191 3         7 $uri->query_param('X-Amz-SignedHeaders' => 'host');
192              
193             # If there was a security token passed, we need to supply it as part of the authorization
194             # because AWS requires it to validate IAM Role temporary credentials.
195              
196 3 50       620 if (defined($self->{security_token})) {
197 0         0 $uri->query_param('X-Amz-Security-Token' => $self->{security_token});
198             }
199              
200             # Since we're providing auth via query parameters, we need to include UNSIGNED-PAYLOAD
201             # http://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-query-string-auth.html
202             # it seems to only be needed for S3.
203              
204 3 50       8 if ($scope =~ /\/s3\/aws4_request$/) {
205 0         0 $self->_sign($request, undef, 'UNSIGNED-PAYLOAD');
206             } else {
207 3         6 $self->_sign($request);
208             }
209              
210 3         103 my ($algorithm,$credential,$signedheaders,$signature) =
211             $request->header('Authorization') =~ /^(\S+) Credential=(\S+), SignedHeaders=(\S+), Signature=(\S+)/;
212 3         93 $uri->query_param_append('X-Amz-Signature' => $signature);
213 3         626 return $uri;
214             }
215              
216              
217             sub _add_date_header {
218 1     1   3 my $self = shift;
219 1         2 my $request = shift;
220 1         1 my $datetime;
221 1 50       3 unless ($datetime = $request->header('x-amz-date')) {
222 1         49 $datetime = $self->_zulu_time($request);
223 1         4 $request->header('x-amz-date'=>$datetime);
224             }
225             }
226              
227             sub _scope {
228 7     7   8 my $self = shift;
229 7         8 my ($request,$region) = @_;
230 7         13 my $host = $request->uri->host;
231 7         130 my $datetime = $self->_datetime($request);
232 7         61 my ($date) = $datetime =~ /^(\d+)T/;
233 7         8 my $service;
234 7 50       49 if ($host =~ /^([\w.-]+)\.s3\.amazonaws.com/) { # S3 bucket virtual host
    50          
    50          
    50          
235 0         0 $service = 's3';
236 0   0     0 $region ||= 'us-east-1';
237             } elsif ($host =~ /^[\w-]+\.s3-([\w-]+)\.amazonaws\.com/) {
238 0         0 $service = 's3';
239 0   0     0 $region ||= $2;
240             } elsif ($host =~ /^(\w+)[-.]([\w-]+)\.amazonaws\.com/) {
241 0         0 $service = $1;
242 0   0     0 $region ||= $2;
243             } elsif ($host =~ /^([\w-]+)\.amazonaws\.com/) {
244 7         16 $service = $1;
245 7         9 $region = 'us-east-1';
246             }
247 7   50     10 $service ||= 's3';
248 7   50     9 $region ||= 'us-east-1'; # default
249 7         38 return "$date/$region/$service/aws4_request";
250             }
251              
252             sub _parse_scope {
253 4     4   5 my $self = shift;
254 4         2 my $scope = shift;
255 4         18 return split '/',$scope;
256             }
257              
258             sub _datetime {
259 14     14   14 my $self = shift;
260 14         13 my $request = shift;
261 14   66     29 return $request->header('x-amz-date') || $self->_zulu_time($request);
262             }
263              
264 7     7   14 sub _algorithm { return 'AWS4-HMAC-SHA256' }
265              
266             sub _sign {
267 4     4   4 my $self = shift;
268 4         7 my ($request,$region,$payload_sha256_hex) = @_;
269 4 50       9 return if $request->header('Authorization'); # don't overwrite
270              
271 4         118 my $datetime = $self->_datetime($request);
272              
273 4 50       34 unless ($request->header('host')) {
274 4         114 my $host = $request->uri->host;
275 4         251 $request->header(host=>$host);
276             }
277              
278 4         125 my $scope = $self->_scope($request,$region);
279 4         5 my ($date,$service);
280 4         10 ($date,$region,$service) = $self->_parse_scope($scope);
281              
282 4         11 my $secret_key = $self->secret_key;
283 4         9 my $access_key = $self->access_key;
284 4         8 my $algorithm = $self->_algorithm;
285              
286 4         8 my ($hashed_request,$signed_headers) = $self->_hash_canonical_request($request,$payload_sha256_hex);
287 4         9 my $string_to_sign = $self->_string_to_sign($datetime,$scope,$hashed_request);
288 4         16 my $signature = $self->_calculate_signature($secret_key,$service,$region,$date,$string_to_sign);
289 4         18 $request->header(Authorization => "$algorithm Credential=$access_key/$scope, SignedHeaders=$signed_headers, Signature=$signature");
290             }
291              
292             sub _zulu_time {
293 13     13   358 my $self = shift;
294 13         9 my $request = shift;
295 13         22 my $date = $request->header('Date');
296 13 50       303 my @datetime = $date ? gmtime(str2time($date)) : gmtime();
297 13         2534 return strftime('%Y%m%dT%H%M%SZ',@datetime);
298             }
299              
300             sub _hash_canonical_request {
301 4     4   5 my $self = shift;
302 4         5 my ($request,$hashed_payload) = @_; # (HTTP::Request,sha256_hex($content))
303 4         11 my $method = $request->method;
304 4         34 my $uri = $request->uri;
305 4   50     27 my $path = $uri->path || '/';
306 4         54 my @params = $uri->query_form;
307 4         266 my $headers = $request->headers;
308 4   33     26 $hashed_payload ||= sha256_hex($request->content);
309              
310             # canonicalize query string
311 4         78 my %canonical;
312 4         12 while (my ($key,$value) = splice(@params,0,2)) {
313 18         27 $key = uri_escape($key);
314 18         130 $value = uri_escape($value);
315 18         112 push @{$canonical{$key}},$value;
  18         58  
316             }
317 4         21 my $canonical_query_string = join '&',map {my $key = $_; map {"$key=$_"} sort @{$canonical{$key}}} sort keys %canonical;
  18         12  
  18         14  
  18         31  
  18         15  
318              
319             # canonicalize the request headers
320 4         5 my (@canonical,%signed_fields);
321 4         14 for my $header (sort map {lc} $headers->header_field_names) {
  11         102  
322 11 100       28 next if $header =~ /^date$/i;
323 7         13 my @values = $headers->header($header);
324             # remove redundant whitespace
325 7         137 foreach (@values ) {
326 7 50       15 next if /^".+"$/;
327 7         9 s/^\s+//;
328 7         10 s/\s+$//;
329 7         12 s/(\s)\s+/$1/g;
330             }
331 7         18 push @canonical,"$header:".join(',',@values);
332 7         12 $signed_fields{$header}++;
333             }
334 4         11 my $canonical_headers = join "\n",@canonical;
335 4         5 $canonical_headers .= "\n";
336 4         7 my $signed_headers = join ';',sort map {lc} keys %signed_fields;
  7         13  
337              
338 4         8 my $canonical_request = join("\n",$method,$path,$canonical_query_string,
339             $canonical_headers,$signed_headers,$hashed_payload);
340 4         28 my $request_digest = sha256_hex($canonical_request);
341            
342 4         19 return ($request_digest,$signed_headers);
343             }
344              
345             sub _string_to_sign {
346 4     4   5 my $self = shift;
347 4         5 my ($datetime,$credential_scope,$hashed_request) = @_;
348 4         8 return join("\n",'AWS4-HMAC-SHA256',$datetime,$credential_scope,$hashed_request);
349             }
350              
351              
352             =item $signing_key = AWS::Signature4->signing_key($secret_access_key,$service_name,$region,$date)
353              
354             Return just the signing key in the event you wish to roll your own signature.
355              
356             =cut
357              
358             sub signing_key {
359 4     4 1 4 my $self = shift;
360 4         4 my ($kSecret,$service,$region,$date) = @_;
361 4         33 my $kDate = hmac_sha256($date,'AWS4'.$kSecret);
362 4         25 my $kRegion = hmac_sha256($region,$kDate);
363 4         24 my $kService = hmac_sha256($service,$kRegion);
364 4         24 my $kSigning = hmac_sha256('aws4_request',$kService);
365 4         5 return $kSigning;
366             }
367              
368             sub _calculate_signature {
369 4     4   3 my $self = shift;
370 4         5 my ($kSecret,$service,$region,$date,$string_to_sign) = @_;
371 4         8 my $kSigning = $self->signing_key($kSecret,$service,$region,$date);
372 4         31 return hmac_sha256_hex($string_to_sign,$kSigning);
373             }
374              
375             1;
376              
377             =head1 SEE ALSO
378              
379             L
380              
381             =head1 AUTHOR
382              
383             Lincoln Stein Elincoln.stein@gmail.comE.
384              
385             Copyright (c) 2014 Ontario Institute for Cancer Research
386              
387             This package and its accompanying libraries is free software; you can
388             redistribute it and/or modify it under the terms of the GPL (either
389             version 1, or at your option, any later version) or the Artistic
390             License 2.0. Refer to LICENSE for the full license text. In addition,
391             please see DISCLAIMER.txt for disclaimers of warranty.
392              
393             =cut
394              
395