File Coverage

lib/AWS/Signature4.pm
Criterion Covered Total %
statement 19 21 90.4
branch n/a
condition n/a
subroutine 7 7 100.0
pod n/a
total 26 28 92.8


line stmt bran cond sub pod time code
1             package AWS::Signature4;
2              
3 1     1   855 use strict;
  1         3  
  1         39  
4 1     1   866 use POSIX 'strftime';
  1         7560  
  1         9  
5 1     1   2140 use URI;
  1         4086  
  1         24  
6 1     1   702 use URI::QueryParam;
  1         652  
  1         23  
7 1     1   5 use URI::Escape;
  1         1  
  1         61  
8 1     1   773 use Digest::SHA 'sha256_hex','hmac_sha256','hmac_sha256_hex';
  1         3741  
  1         78  
9 1     1   421 use Date::Parse;
  0            
  0            
10             use Carp 'croak';
11              
12             our $VERSION = '1.01';
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 = LW::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             my $self = shift;
82             my %args = @_;
83              
84             my ($id,$secret,$token);
85             if (ref $args{-security_token} && $args{-security_token}->can('access_key_id')) {
86             $id = $args{-security_token}->accessKeyId;
87             $secret = $args{-security_token}->secretAccessKey;
88             }
89              
90             $id ||= $args{-access_key} || $ENV{EC2_ACCESS_KEY}
91             or croak "Please provide -access_key parameter or define environment variable EC2_ACCESS_KEY";
92             $secret ||= $args{-secret_key} || $ENV{EC2_SECRET_KEY}
93             or croak "Please provide -secret_key or define environment variable EC2_SECRET_KEY";
94              
95             return bless {
96             access_key => $id,
97             secret_key => $secret,
98             },ref $self || $self;
99             }
100              
101             sub access_key { shift->{access_key } }
102             sub secret_key { shift->{secret_key } }
103              
104             =item $signer->sign($request [,$region] [,$payload_sha256_hex])
105              
106             Given an HTTP::Request object, add the headers required by AWS and
107             then sign it with a version 4 signature by adding an "Authorization"
108             header.
109              
110             The request must include a URL from which the AWS endpoint and service
111             can be derived, such as "ec2.us-east-1.amazonaws.com." In some cases
112             (e.g. S3 bucket operations) the endpoint does not indicate the
113             region. In this case, the region can be forced by passing a defined
114             value for $region. The current date and time will be added to the
115             request using an "X-Amz-Date header." To force the date and time to a
116             fixed value, include the "Date" header in the request.
117              
118             The request content, or "payload" is retrieved from the HTTP::Request
119             object by calling its content() method.. Under some circumstances the
120             payload is not included directly in the request, but is in an external
121             file that will be uploaded as the request is executed. In this case,
122             you must pass a second argument containing the results of running
123             sha256_hex() (from the Digest::SHA module) on the content.
124              
125             The method returns a true value if successful. On errors, it will
126             throw an exception.
127              
128             =item $url = $signer->signed_url($request)
129              
130             This method will generate a signed GET URL for the request. The URL
131             will include everything needed to perform the request.
132              
133             =back
134              
135             =cut
136              
137             sub sign {
138             my $self = shift;
139             my ($request,$region,$payload_sha256_hex) = @_;
140             $self->_add_date_header($request);
141             $self->_sign($request,$region,$payload_sha256_hex);
142             }
143              
144             =item my $url $signer->signed_url($request_or_uri [,$expires])
145              
146             Pass an HTTP::Request, a URI object, or just a plain URL string
147             containing the proper endpoint and parameters needed for an AWS REST
148             API Call. This method will return an appropriately signed request as a
149             URI object, which can be shared with non-AWS users for the purpose of,
150             e.g., accessing an object in a private S3 bucket.
151              
152             Pass an optional $expires argument to indicate that the URL will only
153             be valid for a finite period of time. The value of the argument is in
154             seconds.
155              
156             =cut
157              
158              
159             sub signed_url {
160             my $self = shift;
161             my ($arg1,$expires) = @_;
162            
163             my ($request,$uri);
164              
165             if (ref $arg1 && UNIVERSAL::isa($arg1,'HTTP::Request')) {
166             $request = $arg1;
167             $uri = $request->uri;
168             my $content = $request->content;
169             $uri->query($content) if $content;
170             if (my $date = $request->header('X-Amz-Date') || $request->header('Date')) {
171             $uri->query_param('Date'=>$date);
172             }
173             }
174              
175             $uri ||= URI->new($arg1);
176             my $date = $uri->query_param_delete('Date') || $uri->query_param_delete('X-Amz-Date');
177             $request = HTTP::Request->new(GET=>$uri);
178             $request->header('Date'=> $date);
179             $uri = $request->uri; # because HTTP::Request->new() copies the uri!
180              
181             return $uri if $uri->query_param('X-Amz-Signature');
182              
183             $uri->query_param('X-Amz-Algorithm' => $self->_algorithm);
184             $uri->query_param('X-Amz-Credential' => $self->access_key . '/' . $self->_scope($request));
185             $uri->query_param('X-Amz-Date' => $self->_datetime($request));
186             $uri->query_param('X-Amz-Expires' => $expires) if $expires;
187             $uri->query_param('X-Amz-SignedHeaders' => 'host');
188              
189             $self->_sign($request);
190             my ($algorithm,$credential,$signedheaders,$signature) =
191             $request->header('Authorization') =~ /^(\S+) Credential=(\S+), SignedHeaders=(\S+), Signature=(\S+)/;
192             $uri->query_param_append('X-Amz-Signature' => $signature);
193             return $uri;
194             }
195              
196              
197             sub _add_date_header {
198             my $self = shift;
199             my $request = shift;
200             my $datetime;
201             unless ($datetime = $request->header('x-amz-date')) {
202             $datetime = $self->_zulu_time($request);
203             $request->header('x-amz-date'=>$datetime);
204             }
205             }
206              
207             sub _scope {
208             my $self = shift;
209             my ($request,$region) = @_;
210             my $host = $request->uri->host;
211             my $datetime = $self->_datetime($request);
212             my ($date) = $datetime =~ /^(\d+)T/;
213             my $service;
214             if ($host =~ /^([\w.-]+)\.s3\.amazonaws.com/) { # S3 bucket virtual host
215             $service = 's3';
216             $region ||= 'us-east-1';
217             } elsif ($host =~ /^[\w-]+\.s3-([\w-]+)\.amazonaws\.com/) {
218             $service = 's3';
219             $region ||= $2;
220             } elsif ($host =~ /^(\w+)[-.]([\w-]+)\.amazonaws\.com/) {
221             $service = $1;
222             $region ||= $2;
223             } elsif ($host =~ /^([\w-]+)\.amazonaws\.com/) {
224             $service = $1;
225             $region = 'us-east-1';
226             }
227             $service ||= 's3';
228             $region ||= 'us-east-1'; # default
229             return "$date/$region/$service/aws4_request";
230             }
231              
232             sub _parse_scope {
233             my $self = shift;
234             my $scope = shift;
235             return split '/',$scope;
236             }
237              
238             sub _datetime {
239             my $self = shift;
240             my $request = shift;
241             return $request->header('x-amz-date') || $self->_zulu_time($request);
242             }
243              
244             sub _algorithm { return 'AWS4-HMAC-SHA256' }
245              
246             sub _sign {
247             my $self = shift;
248             my ($request,$region,$payload_sha256_hex) = @_;
249             return if $request->header('Authorization'); # don't overwrite
250              
251             my $datetime = $self->_datetime($request);
252              
253             unless ($request->header('host')) {
254             my $host = $request->uri->host;
255             $request->header(host=>$host);
256             }
257              
258             my $scope = $self->_scope($request,$region);
259             my ($date,$service);
260             ($date,$region,$service) = $self->_parse_scope($scope);
261              
262             my $secret_key = $self->secret_key;
263             my $access_key = $self->access_key;
264             my $algorithm = $self->_algorithm;
265              
266             my ($hashed_request,$signed_headers) = $self->_hash_canonical_request($request,$payload_sha256_hex);
267             my $string_to_sign = $self->_string_to_sign($datetime,$scope,$hashed_request);
268             my $signature = $self->_calculate_signature($secret_key,$service,$region,$date,$string_to_sign);
269             $request->header(Authorization => "$algorithm Credential=$access_key/$scope, SignedHeaders=$signed_headers, Signature=$signature");
270             }
271              
272             sub _zulu_time {
273             my $self = shift;
274             my $request = shift;
275             my $date = $request->header('Date');
276             my @datetime = $date ? gmtime(str2time($date)) : gmtime();
277             return strftime('%Y%m%dT%H%M%SZ',@datetime);
278             }
279              
280             sub _hash_canonical_request {
281             my $self = shift;
282             my ($request,$hashed_payload) = @_; # (HTTP::Request,sha256_hex($content))
283             my $method = $request->method;
284             my $uri = $request->uri;
285             my $path = $uri->path || '/';
286             my @params = $uri->query_form;
287             my $headers = $request->headers;
288             $hashed_payload ||= sha256_hex($request->content);
289              
290             # canonicalize query string
291             my %canonical;
292             while (my ($key,$value) = splice(@params,0,2)) {
293             $key = uri_escape($key);
294             $value = uri_escape($value);
295             push @{$canonical{$key}},$value;
296             }
297             my $canonical_query_string = join '&',map {my $key = $_; map {"$key=$_"} sort @{$canonical{$key}}} sort keys %canonical;
298              
299             # canonicalize the request headers
300             my (@canonical,%signed_fields);
301             for my $header (sort map {lc} $headers->header_field_names) {
302             next if $header =~ /^date$/i;
303             my @values = $headers->header($header);
304             # remove redundant whitespace
305             foreach (@values ) {
306             next if /^".+"$/;
307             s/^\s+//;
308             s/\s+$//;
309             s/(\s)\s+/$1/g;
310             }
311             push @canonical,"$header:".join(',',@values);
312             $signed_fields{$header}++;
313             }
314             my $canonical_headers = join "\n",@canonical;
315             $canonical_headers .= "\n";
316             my $signed_headers = join ';',sort map {lc} keys %signed_fields;
317              
318             my $canonical_request = join("\n",$method,$path,$canonical_query_string,
319             $canonical_headers,$signed_headers,$hashed_payload);
320             my $request_digest = sha256_hex($canonical_request);
321            
322             return ($request_digest,$signed_headers);
323             }
324              
325             sub _string_to_sign {
326             my $self = shift;
327             my ($datetime,$credential_scope,$hashed_request) = @_;
328             return join("\n",'AWS4-HMAC-SHA256',$datetime,$credential_scope,$hashed_request);
329             }
330              
331             sub _calculate_signature {
332             my $self = shift;
333             my ($kSecret,$service,$region,$date,$string_to_sign) = @_;
334             my $kDate = hmac_sha256($date,'AWS4'.$kSecret);
335             my $kRegion = hmac_sha256($region,$kDate);
336             my $kService = hmac_sha256($service,$kRegion);
337             my $kSigning = hmac_sha256('aws4_request',$kService);
338             return hmac_sha256_hex($string_to_sign,$kSigning);
339             }
340              
341             1;
342              
343             =head1 SEE ALSO
344              
345             L
346              
347             =head1 AUTHOR
348              
349             Lincoln Stein Elincoln.stein@gmail.comE.
350              
351             Copyright (c) 2014 Ontario Institute for Cancer Research
352              
353             This package and its accompanying libraries is free software; you can
354             redistribute it and/or modify it under the terms of the GPL (either
355             version 1, or at your option, any later version) or the Artistic
356             License 2.0. Refer to LICENSE for the full license text. In addition,
357             please see DISCLAIMER.txt for disclaimers of warranty.
358              
359             =cut
360              
361