File Coverage

blib/lib/AWS/S3/Signer/V4.pm
Criterion Covered Total %
statement 209 213 98.1
branch 43 56 76.7
condition 44 76 57.8
subroutine 27 27 100.0
pod 4 7 57.1
total 327 379 86.2


line stmt bran cond sub pod time code
1             package AWS::S3::Signer::V4;
2              
3 6     6   876646 use strict;
  6         12  
  6         263  
4 6     6   3404 use POSIX 'strftime';
  6         44691  
  6         83  
5 6     6   13674 use URI;
  6         20494  
  6         190  
6 6     6   1013 use URI::QueryParam;
  6         326  
  6         171  
7 6     6   34 use URI::Escape;
  6         11  
  6         473  
8 6     6   1252 use Digest::SHA 'sha256_hex', 'hmac_sha256', 'hmac_sha256_hex';
  6         10703  
  6         666  
9 6     6   3447 use Date::Parse;
  6         45044  
  6         966  
10 6     6   75 use Carp 'croak';
  6         40  
  6         334  
11 6     6   1118 use HTTP::Request;
  6         37361  
  6         560  
12              
13             # https://webservices.amazon.com/paapi5/documentation/common-request-parameters.html#host-and-region
14 6         18855 use constant PAAPI_REGION => {
15             qw/
16             webservices.amazon.com.au us-west-2
17             webservices.amazon.com.br us-east-1
18             webservices.amazon.ca us-east-1
19             webservices.amazon.fr eu-west-1
20             webservices.amazon.de eu-west-1
21             webservices.amazon.in eu-west-1
22             webservices.amazon.it eu-west-1
23             webservices.amazon.co.jp us-west-2
24             webservices.amazon.com.mx us-east-1
25             webservices.amazon.sg us-west-2
26             webservices.amazon.es eu-west-1
27             webservices.amazon.com.tr eu-west-1
28             webservices.amazon.ae eu-west-1
29             webservices.amazon.co.uk eu-west-1
30             webservices.amazon.com us-east-1
31             /
32 6     6   98 };
  6         12  
33              
34             =head1 NAME
35              
36             AWS::S3::Signer::V4 - Create a version4 signature for Amazon Web Services
37              
38             =head1 SYNOPSIS
39              
40             use AWS::S3::Signer::V4;
41             use HTTP::Request::Common;
42             use LWP;
43              
44             my $signer = AWS::S3::Signer::V4->new(-access_key => 'AKIDEXAMPLE',
45             -secret_key => 'wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY');
46             my $ua = LWP::UserAgent->new();
47              
48             # Example POST request
49             my $request = POST('https://iam.amazonaws.com',
50             [Action=>'ListUsers',
51             Version=>'2010-05-08']);
52             $signer->sign($request);
53             my $response = $ua->request($request);
54              
55             # Example GET request
56             my $uri = URI->new('https://iam.amazonaws.com');
57             $uri->query_form(Action=>'ListUsers',
58             Version=>'2010-05-08');
59              
60             my $url = $signer->signed_url($uri); # This gives a signed URL that can be fetched by a browser
61             my $response = $ua->get($url);
62              
63             =head1 DESCRIPTION
64              
65             This module implement's Amazon Web Service's Signature version 4
66             (http://docs.aws.amazon.com/general/latest/gr/signature-version-4.html).
67              
68             =head1 METHODS
69              
70             =over 4
71              
72             =item $signer = AWS::S3::Signer::V4->new(-access_key => $account_id,-secret_key => $private_key);
73              
74             Create a signing object using your AWS account ID and secret key. You
75             may also use the temporary security tokens received from Amazon's STS
76             service, either by passing the access and secret keys derived from the
77             token, or by passing a VM::EC2::Security::Token produced by the
78             VM::EC2 module.
79              
80             Arguments:
81              
82             Argument name Argument Value
83             ------------- --------------
84             -access_key An AWS access key (account ID)
85              
86             -secret_key An AWS secret key
87              
88             -security_token A VM::EC2::Security::Token object
89              
90             -service An AWS service
91              
92             -region An AWS region
93              
94              
95             If a security token is provided, it overrides any values given for
96             -access_key or -secret_key.
97              
98             If the environment variables EC2_ACCESS_KEY and/or EC2_SECRET_KEY are
99             set, their contents are used as defaults for -access_key and
100             -secret_key.
101              
102             If -service and/or -region is not provided, they are automtically determined
103             according to endpoint.
104              
105             =cut
106              
107             sub new {
108 31     31 1 6004 my $self = shift;
109 31         160 my %args = @_;
110              
111 31         82 my ( $id, $secret, $token, $region, $service );
112 31 50 33     194 if ( ref $args{-security_token}
113             && $args{-security_token}->can('access_key_id') )
114             {
115 0         0 $id = $args{-security_token}->accessKeyId;
116 0         0 $secret = $args{-security_token}->secretAccessKey;
117             }
118              
119             $id ||= $args{-access_key} || $ENV{EC2_ACCESS_KEY}
120 31 50 33     287 or croak
      33        
121             "Please provide -access_key parameter or define environment variable EC2_ACCESS_KEY";
122             $secret ||= $args{-secret_key} || $ENV{EC2_SECRET_KEY}
123 31 50 33     213 or croak
      33        
124             "Please provide -secret_key or define environment variable EC2_SECRET_KEY";
125 31   33     189 $region = $args{-region} || $ENV{EC2_REGION};
126 31   33     183 $service = $args{-service} || $ENV{EC2_SERVICE};
127              
128             return bless {
129             access_key => $id,
130             secret_key => $secret,
131             region => $region,
132             region => $args{-region},
133             service => $args{-service},
134             (
135             defined( $args{-security_token} )
136             ? ( security_token => $args{-security_token} )
137 31 50 33     1627 : ()
138             ),
139             },
140             ref $self || $self;
141             }
142              
143 41     41 0 125 sub access_key { shift->{access_key} }
144 35     35 0 108 sub secret_key { shift->{secret_key} }
145              
146             =item $signer->sign($request [,$region] [,$payload_sha256_hex])
147              
148             Given an HTTP::Request object, add the headers required by AWS and
149             then sign it with a version 4 signature by adding an "Authorization"
150             header.
151              
152             The request must include a URL from which the AWS endpoint and service
153             can be derived, such as "ec2.us-east-1.amazonaws.com." In some cases
154             (e.g. S3 bucket operations) the endpoint does not indicate the
155             region. In this case, the region can be forced by passing a defined
156             value for $region. The current date and time will be added to the
157             request using an "X-Amz-Date header." To force the date and time to a
158             fixed value, include the "Date" header in the request.
159              
160             The request content, or "payload" is retrieved from the HTTP::Request
161             object by calling its content() method.. Under some circumstances the
162             payload is not included directly in the request, but is in an external
163             file that will be uploaded as the request is executed. In this case,
164             you must pass a second argument containing the results of running
165             sha256_hex() (from the Digest::SHA module) on the content.
166              
167             The method returns a true value if successful. On errors, it will
168             throw an exception.
169              
170             =item $url = $signer->signed_url($request)
171              
172             This method will generate a signed GET URL for the request. The URL
173             will include everything needed to perform the request.
174              
175             =cut
176              
177             sub sign {
178 29     29 1 13356 my $self = shift;
179 29         94 my ( $request, $region, $payload_sha256_hex ) = @_;
180 29         168 $self->_add_date_header($request);
181 29         2131 $self->_sign( $request, $region, $payload_sha256_hex );
182             }
183              
184             =item my $url $signer->signed_url($request_or_uri [,$expires] [,$verb])
185              
186             Pass an HTTP::Request, a URI object, or just a plain URL string
187             containing the proper endpoint and parameters needed for an AWS REST
188             API Call. This method will return an appropriately signed request as a
189             URI object, which can be shared with non-AWS users for the purpose of,
190             e.g., accessing an object in a private S3 bucket.
191              
192             Pass an optional $expires argument to indicate that the URL will only
193             be valid for a finite period of time. The value of the argument is in
194             seconds.
195              
196             Pass an optional verb which is useful for HEAD requests, this defaults to GET.
197              
198             =cut
199              
200             sub signed_url {
201 6     6 1 5213 my $self = shift;
202 6         16 my ( $arg1, $expires, $verb ) = @_;
203 6         11 my ( $request, $uri );
204              
205 6   100     33 $verb ||= 'GET';
206 6         15 $verb = uc($verb);
207              
208 6         24 my $incorrect_verbs = {
209             POST => 1,
210             PUT => 1
211             };
212              
213 6 50       20 if ( exists( $incorrect_verbs->{$verb} ) ) {
214 0         0 die "Use AWS::S3::Signer::V4->sign sub for $verb method";
215             }
216              
217 6 100 100     43 if ( ref $arg1 && UNIVERSAL::isa( $arg1, 'HTTP::Request' ) ) {
218 2         4 $request = $arg1;
219 2         7 $uri = $request->uri;
220 2         21 my $content = $request->content;
221 2 50       30 $uri->query($content) if $content;
222 2 50 33     9 if ( my $date =
223             $request->header('X-Amz-Date') || $request->header('Date') )
224             {
225 2         260 $uri->query_param( 'Date' => $date );
226             }
227             }
228              
229 6   66     602 $uri ||= URI->new($arg1);
230 6   66     764 my $date = $uri->query_param_delete('Date')
231             || $uri->query_param_delete('X-Amz-Date');
232 6         1078 $request = HTTP::Request->new( $verb => $uri );
233 6         522 $request->header( 'Date' => $date );
234 6         467 $uri = $request->uri; # because HTTP::Request->new() copies the uri!
235              
236 6 50       61 return $uri if $uri->query_param('X-Amz-Signature');
237              
238 6         414 my $scope = $self->_scope($request);
239              
240 6         20 $uri->query_param( 'X-Amz-Algorithm' => $self->_algorithm );
241 6         1194 $uri->query_param( 'X-Amz-Credential' => $self->access_key . '/' . $scope );
242 6         1599 $uri->query_param( 'X-Amz-Date' => $self->_datetime($request) );
243 6 100       1949 $uri->query_param( 'X-Amz-Expires' => $expires ) if $expires;
244 6         534 $uri->query_param( 'X-Amz-SignedHeaders' => 'host' );
245              
246             # If there was a security token passed, we need to supply it as part of the authorization
247             # because AWS requires it to validate IAM Role temporary credentials.
248              
249 6 50       2076 if ( defined( $self->{security_token} ) ) {
250 0         0 $uri->query_param( 'X-Amz-Security-Token' => $self->{security_token} );
251             }
252              
253             # Since we're providing auth via query parameters, we need to include UNSIGNED-PAYLOAD
254             # http://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-query-string-auth.html
255             # it seems to only be needed for S3.
256              
257 6 100       30 if ( $scope =~ /\/s3\/aws4_request$/ ) {
258 3         15 $self->_sign( $request, undef, 'UNSIGNED-PAYLOAD' );
259             }
260             else {
261 3         10 $self->_sign($request);
262             }
263              
264 6         389 my ( $algorithm, $credential, $signedheaders, $signature ) =
265             $request->header('Authorization') =~
266             /^(\S+) Credential=(\S+), SignedHeaders=(\S+), Signature=(\S+)/;
267 6         312 $uri->query_param_append( 'X-Amz-Signature' => $signature );
268 6         2416 return $uri;
269             }
270              
271             sub _add_date_header {
272 29     29   71 my $self = shift;
273 29         60 my $request = shift;
274 29         63 my $datetime;
275 29 50       135 unless ( $datetime = $request->header('x-amz-date') ) {
276 29         2145 $datetime = $self->_zulu_time($request);
277 29         169 $request->header( 'x-amz-date' => $datetime );
278             }
279             }
280              
281             sub _scope {
282 41     41   90 my $self = shift;
283 41         140 my ( $request, $region ) = @_;
284 41         137 my $host = $request->uri->host;
285 41         1673 my $datetime = $self->_datetime($request);
286 41         1741 my ($date) = $datetime =~ /^(\d+)T/;
287 41         87 my $service;
288              
289 41         166 ( $service, $region ) = $self->parse_host( $host, $region );
290              
291 41   50     391 $service ||= $self->{service} || 's3';
      66        
292 41   50     262 $region ||= $self->{region} || 'us-east-1'; # default
      66        
293 41         183 return "$date/$region/$service/aws4_request";
294             }
295              
296             sub parse_host {
297 1208     1208 0 1959285 my $self = shift;
298 1208         2591 my $host = shift;
299 1208         2007 my $region = shift;
300              
301             # this entire thing should probably refactored into its own
302             # distribution, a la https://github.com/zirkelc/amazon-s3-url
303              
304             # https://docs.aws.amazon.com/prescriptive-guidance/latest/defining-bucket-names-data-lakes/faq.html
305             # Only lowercase letters, numbers, dashes, and dots are allowed in S3 bucket names.
306             # Bucket names must be three to 63 characters in length,
307             # must begin and end with a number or letter,
308             # and cannot be in an IP address format.
309 1208         1900 my $bucket_re = '[a-z0-9][a-z0-9\-\.]{1,61}[a-z0-9]';
310 1208         1986 my $domain_re = 'amazonaws\.com';
311 1208         1855 my $region_re = '(?:af|ap|ca|eu|il|me|mx|sa|us)-[a-z]+-\d';
312              
313 1208         1991 my ( $service, $url_style );
314              
315             # listed in order of appearance found in the docs:
316             # https://community.aws/content/2biM1C0TkMkvJ2BLICiff8MKXS9/format-and-parse-amazon-s3-url?lang=en
317 1208 100       31953 if ( $host =~ /^(\w+)([-.])($region_re)\.$domain_re/ ) {
    100          
    100          
    100          
    100          
    100          
    100          
    100          
318 384         1327 $service = $1;
319 384   66     2119 $region ||= $3;
320 384 100       1253 $url_style = $2 eq '-' ? 'regional dash-style' : 'regional dot-style';
321             }
322             elsif ( $host =~ /^$bucket_re\.($region_re)\.s3\.$domain_re/ ) {
323 192         459 $service = 's3';
324 192   66     1009 $region ||= $1;
325 192         473 $url_style = 'regional virtual-hosted-style';
326             }
327             elsif ( $host =~ /^s3\.$domain_re/ ) {
328 192         397 $service = 's3';
329 192         458 $region = 'us-east-1';
330 192         412 $url_style = 'legacy with path-style';
331             }
332             elsif ( $host =~ /^$bucket_re\.s3\.$domain_re/ ) {
333 192         498 $service = 's3';
334 192   100     824 $region ||= 'us-east-1';
335 192         353 $url_style = 'legacy with virtual-hosted-style';
336             }
337             elsif ( $host =~ /^$bucket_re\.s3[\.-]($region_re)\.$domain_re/ ) {
338 2         6 $service = 's3';
339 2   33     16 $region ||= $1;
340 2         4 $url_style = 'regional virtual-hosted-style';
341             }
342             elsif ($host =~ /^([\w-]+)\.([\w-]+)\.$domain_re/) {
343 193         862 $service = $1;
344 193   66     1256 $region ||= $2;
345 193         350 $url_style = 'legacy path-style service';
346             }
347             elsif ( $host =~ /^([\w-]+)\.$domain_re/ ) {
348 7         20 $service = $1;
349 7         13 $region = 'us-east-1';
350 7         38 $url_style = 'legacy path-style';
351             }
352             elsif ( exists PAAPI_REGION->{$host} ) {
353 15         64 $service = 'ProductAdvertisingAPI';
354 15         43 $region = PAAPI_REGION->{$host};
355             }
356              
357 1208         6563 return ( $service, $region, $url_style );
358             }
359              
360             sub _parse_scope {
361 35     35   71 my $self = shift;
362 35         75 my $scope = shift;
363 35         199 return split '/', $scope;
364             }
365              
366             sub _datetime {
367 82     82   147 my $self = shift;
368 82         153 my $request = shift;
369 82   66     222 return $request->header('x-amz-date') || $self->_zulu_time($request);
370             }
371              
372 41     41   111 sub _algorithm { return 'AWS4-HMAC-SHA256' }
373              
374             sub _sign {
375 35     35   80 my $self = shift;
376 35         110 my ( $request, $region, $payload_sha256_hex ) = @_;
377 35 50       128 return if $request->header('Authorization'); # don't overwrite
378              
379 35         1930 my $datetime = $self->_datetime($request);
380              
381 35 50       1551 unless ( $request->header('host') ) {
382 35         1694 my $host = $request->uri->host;
383 35         1935 $request->header( host => $host );
384             }
385              
386 35         2154 my $scope = $self->_scope( $request, $region );
387 35         102 my ( $date, $service );
388 35         134 ( $date, $region, $service ) = $self->_parse_scope($scope);
389              
390 35         146 my $secret_key = $self->secret_key;
391 35         118 my $access_key = $self->access_key;
392 35         101 my $algorithm = $self->_algorithm;
393              
394 35         147 my ( $hashed_request, $signed_headers ) =
395             $self->_hash_canonical_request( $request, $payload_sha256_hex );
396 35         156 my $string_to_sign =
397             $self->_string_to_sign( $datetime, $scope, $hashed_request );
398 35         136 my $signature =
399             $self->_calculate_signature( $secret_key, $service, $region, $date,
400             $string_to_sign );
401 35         247 $request->header( Authorization =>
402             "$algorithm Credential=$access_key/$scope, SignedHeaders=$signed_headers, Signature=$signature"
403             );
404             }
405              
406             sub _zulu_time {
407 53     53   1272 my $self = shift;
408 53         105 my $request = shift;
409 53         157 my $date = $request->header('Date');
410 53 100       2659 my @datetime = $date ? gmtime( str2time($date) ) : gmtime();
411 53         21236 return strftime( '%Y%m%dT%H%M%SZ', @datetime );
412             }
413              
414             sub _hash_canonical_request {
415 35     35   73 my $self = shift;
416 35         91 my ( $request, $hashed_payload ) =
417             @_; # (HTTP::Request,sha256_hex($content))
418 35         155 my $method = $request->method;
419 35         546 my $uri = $request->uri;
420 35   100     335 my $path = $uri->path || '/';
421 35         719 my @params = $uri->query_form;
422 35         1817 my $headers = $request->headers;
423 35   66     335 $hashed_payload ||= sha256_hex( $request->content );
424              
425             # canonicalize query string
426              
427             # in the case of the S3 api, but its still expected to be part of a
428             # canonical request.
429 35 100 100     313 if (scalar(@params) == 0 && defined($uri->query) && $uri->query ne '') {
      66        
430 1         29 push @params, ($uri->query, '');
431             }
432              
433 35         341 my %canonical;
434 35         203 while ( my ( $key, $value ) = splice( @params, 0, 2 ) ) {
435 44         132 $key = uri_escape($key);
436 44         823 $value = uri_escape($value);
437 44         618 push @{ $canonical{$key} }, $value;
  44         239  
438             }
439             my $canonical_query_string = join '&', map {
440 35         197 my $key = $_;
  44         58  
441 44         64 map { "$key=$_" } sort @{ $canonical{$key} }
  44         135  
  44         75  
442             } sort keys %canonical;
443              
444             # canonicalize the request headers
445 35         89 my ( @canonical, %signed_fields );
446 35         187 for my $header ( sort map { lc } $headers->header_field_names ) {
  138         1909  
447 138 100       456 next if $header =~ /^date$/i;
448 105         313 my @values = $headers->header($header);
449              
450             # remove redundant whitespace
451 105         4105 foreach (@values) {
452 105 50       309 next if /^".+"$/;
453 105         325 s/^\s+//;
454 105         253 s/\s+$//;
455 105         291 s/(\s)\s+/$1/g;
456             }
457 105         481 push @canonical, "$header:" . join( ',', @values );
458 105         316 $signed_fields{$header}++;
459             }
460 35         150 my $canonical_headers = join "\n", @canonical;
461 35         81 $canonical_headers .= "\n";
462 35         160 my $signed_headers = join ';', sort map { lc } keys %signed_fields;
  105         318  
463              
464 35         147 my $canonical_request = join( "\n",
465             $method, $path, $canonical_query_string,
466             $canonical_headers, $signed_headers, $hashed_payload );
467 35         445 my $request_digest = sha256_hex($canonical_request);
468              
469 35         238 return ( $request_digest, $signed_headers );
470             }
471              
472             sub _string_to_sign {
473 35     35   67 my $self = shift;
474 35         106 my ( $datetime, $credential_scope, $hashed_request ) = @_;
475 35         126 return join( "\n",
476             'AWS4-HMAC-SHA256', $datetime, $credential_scope, $hashed_request );
477             }
478              
479             =item $signing_key = AWS::S3::Signer::V4->signing_key($secret_access_key,$service_name,$region,$date)
480              
481             Return just the signing key in the event you wish to roll your own signature.
482              
483             =cut
484              
485             sub signing_key {
486 35     35 1 60 my $self = shift;
487 35         98 my ( $kSecret, $service, $region, $date ) = @_;
488 35         402 my $kDate = hmac_sha256( $date, 'AWS4' . $kSecret );
489 35         338 my $kRegion = hmac_sha256( $region, $kDate );
490 35         305 my $kService = hmac_sha256( $service, $kRegion );
491 35         297 my $kSigning = hmac_sha256( 'aws4_request', $kService );
492 35         94 return $kSigning;
493             }
494              
495             sub _calculate_signature {
496 35     35   70 my $self = shift;
497 35         127 my ( $kSecret, $service, $region, $date, $string_to_sign ) = @_;
498 35         120 my $kSigning = $self->signing_key( $kSecret, $service, $region, $date );
499 35         444 return hmac_sha256_hex( $string_to_sign, $kSigning );
500             }
501              
502             1;
503              
504             =back
505              
506             =head1 SEE ALSO
507              
508             L<VM::EC2>
509              
510             =head1 AUTHOR
511              
512             Lincoln Stein E<lt>lincoln.stein@gmail.comE<gt>.
513              
514             Forked by leejo for use in L<AWS::S3>.
515              
516             Copyright (c) 2014 Ontario Institute for Cancer Research
517              
518             This package and its accompanying libraries is free software; you can
519             redistribute it and/or modify it under the terms of the GPL (either
520             version 1, or at your option, any later version) or the Artistic
521             License 2.0. Refer to LICENSE for the full license text. In addition,
522             please see DISCLAIMER.txt for disclaimers of warranty.
523              
524             =cut
525