AWS-Signature-V4
view release on metacpan or search on metacpan
lib/AWS/Signature/V4.pm view on Meta::CPAN
sub _body_ref ($args) {
my $body = $args->{body};
return \$args->{body} unless ref $body;
fail 400, 'body must be a scalar or a reference to a scalar'
unless ref($body) eq 'SCALAR';
return $body;
}
# AWS trims and collapses spaces, so ASCII whitespace only (/a): with
# unicode_strings, \s would also take the bytes 0xA0 and 0x85 of UTF-8 text
sub _trim ($v) {
$v =~ s{\A\s+|\s+\z}{}gmxa; # remove leading/trailing whitespaces
$v =~ s{\s+}{ }gas; # normalize internal whitespaces
return $v;
}
# (host, path, query) of a url; the host is lowercased and the port is
# removed if it is the default one, as user agents do before sending it
sub _split_url ($url) {
# every part is optional, so this always matches
my ($scheme, $auth, $path, $query) =
$url =~ m{\A (?: ([A-Za-z][A-Za-z0-9+.-]*) :// ([^/?\#]*) )?
([^?\#]*) (?: \? ([^\#]*) )? }x;
fail 400, 'url path must start with "/": give an absolute url, or a path with a Host header'
if length $path && $path !~ m{\A/};
fail 400, 'url query must not contain "+": write %20 for a space, %2B for a plus sign'
if defined $query && $query =~ m{\+};
return (undef, $path, $query) unless defined $auth && length $auth;
fail 400, 'url must not have user information (user@host)' if $auth =~ m{@};
my ($host, $port) = $auth =~ m{\A
(\[[0-9A-Fa-f:.]+\] | [A-Za-z0-9._~-]+) # host
(?: : ([0-9]*) )? # optional port
\z}x or fail 400, 'invalid url: ' . shown($auth);
$host = lc $host;
if (length($port // '')) {
$port += 0;
fail 400, "invalid port: $port" if $port < 1 || $port > 65535;
my $lcscheme = lc($scheme);
my $default = $lcscheme eq 'https' ? 443
: $lcscheme eq 'http' ? 80
: 0;
$host .= ':' . $port if $port != $default;
}
return ($host, $path, $query);
}
sub _shown_list (@list) {
return join ', ',
map { join '', q{"}, shown($_), q{"} }
sort { $a cmp $b } @list;
}
use namespace::clean; # imported or plain functions must not become methods
has service => (is => 'ro');
has region => (is => 'ro');
# credentials: {access_key_id, secret_access_key, session_token?}
has credentials => (is => 'ro', coerce => \&_copy, isa => sub { _hash_or_undef(credentials => @_) });
# Certificate-based variant, as used by IAM Roles Anywhere.
# certificate / certificate_file: PEM or DER text, or path of a file with it
# chain / chain_files: optional arrayref of PEM/DER intermediate certificates,
# or of paths of files with them; a PEM item can be a bundle.
# chain can also be a plain string: a PEM-encoded bundle, and
# chain_files a plain string: the path of one file
# key_type: 'RSA' or 'ECDSA'
# signer: sub ($bytes_to_sign) -> signature (DER for ECDSA, PKCS#1 v1.5
# for RSA), computed with SHA-256; or, alternatively,
# private_key_file / private_key: PEM or DER key (path or content), signed
# with CryptX; private_key_password if it is encrypted
has x509 => (is => 'ro', coerce => \&_copy, isa => sub { _hash_or_undef(x509 => @_) });
# S3 is the odd one out: this is the only place that knows how to tell. S3
# Object Lambda, S3 on Outposts and S3 Express sign with names of their own,
# under the same rules.
has _is_s3 => (is => 'lazy', init_arg => undef);
sub _build__is_s3 ($self) { $self->service =~ m{\As3(?:-object-lambda|-outposts|express)?\z} }
has double_encode => (is => 'ro', lazy => 1, default => sub ($self) { !$self->_is_s3 });
has normalize_path => (is => 'ro', lazy => 1, default => sub ($self) { !$self->_is_s3 });
has payload_header => (is => 'ro', lazy => 1, default => sub ($self) { $self->_is_s3 });
# FIXME review this decision: "undef" in Perl has a history of meaning
# "false" but here we're saying that it means "do the default".
# an undefined option is like a missing one: the default applies
around BUILDARGS => sub ($orig, $class, @args) {
my $args = $class->$orig(@args);
defined $args->{$_} or delete $args->{$_}
for qw< double_encode normalize_path payload_header >;
return $args;
};
# The variant in use, credentials or x509, which knows how to sign and what
# goes with the request: see AWS::Signature::V4::Credentials and ::X509
has _auth => (is => 'lazy', init_arg => undef);
sub BUILD ($self, $args) {
for my $name (qw< service region >) {
my $value = $self->$name;
defined($value) || fail 400, qq{missing parameter "$name"};
fail 400, qq{invalid "$name" } . shown($value)
. ': only letters, digits, ".", "_" and "-" are allowed'
unless $value =~ m{\A[-A-Za-z0-9._]+\z};
}
# make sure the caller provides us *exactly* one of credentials/X509
my $x509_params = $self->x509;
my $credentials_params = $self->credentials;
fail 400, 'provide either "credentials" or "x509", not both'
if $credentials_params && $x509_params;
fail 400, 'provide (exactly) one of "credentials" or "x509"'
unless $credentials_params || $x509_params;
$self->_auth; # force building and fail fast
# after using the parameters, we get rid of sensitive data in the input
# hash for X509
delete($x509_params->{$_}) for qw< private_key private_key_password >;
return;
}
sub _build__auth ($self) {
return $self->credentials
? AWS::Signature::V4::Credentials->new($self->credentials->%*)
: AWS::Signature::V4::X509->new($self->x509->%*);
}
sub algorithm ($self) { $self->_auth->algorithm }
# sign(method => ..., url => ..., headers => ..., body => ..., time => ...)
# Returns a hashref:
# headers: all headers to set on the request (name => value)
# including Authorization, X-Amz-Date, Host if missing...
# authorization: value of the Authorization header
# signature, canonical_request, string_to_sign, signed_headers, scope
sub sign ($self, @args) {
my %args = _args(sign => @args);
# some arguments apply to the "payload" variant and some to the
# "streaming" variant only, we need some more input validation.
# $not_allowed below collects the name of the parameter that are not
# allowed for the variant that is requested.
my $streaming = $args{streaming};
my $not_allowed = ALLOWED_ARGS->{$streaming ? 'payload' : 'streaming'};
my @misplaced = grep { exists $args{$_} } $not_allowed->@*;
fail 400, qq{"$misplaced[0]" }
. ($streaming ? 'does not apply to streaming' : 'needs streaming')
if @misplaced;
my $method = _method($args{method} // fail 400, 'missing parameter "method"');
my ($h, $path, $query, $amzdate, $scope) = $self->_request(\%args);
$h->{'x-amz-date'} = $amzdate;
my $given = _given_payload_hash($h);
my ($unsigned_chunks, @trailer_names);
my $payload_hash;
if ($streaming) {
fail 400, 'streaming must be false, 1, "signed", or "unsigned"'
unless $streaming =~ m{\A(?:1|signed|unsigned)\z};
$unsigned_chunks = $streaming eq 'unsigned';
@trailer_names = _trailer_names(\%args);
fail 400, 'unsigned streaming needs "checksum" or "trailers"'
if $unsigned_chunks && !@trailer_names;
fail 400, 'signed streaming needs the credentials variant, not x509'
if !$unsigned_chunks && !$self->_auth->can_sign_chunks;
my $len = $args{decoded_content_length}
// fail 400, 'streaming needs "decoded_content_length"';
fail 400, 'decoded_content_length must be a non-negative integer'
unless $len =~ m{\A[0-9]+\z};
$payload_hash =
$unsigned_chunks ? 'STREAMING-UNSIGNED-PAYLOAD-TRAILER'
: @trailer_names ? 'STREAMING-AWS4-HMAC-SHA256-PAYLOAD-TRAILER'
: 'STREAMING-AWS4-HMAC-SHA256-PAYLOAD';
$h->{'x-amz-decoded-content-length'} = $len;
$h->{'x-amz-trailer'} = join ',', @trailer_names if @trailer_names;
# whatever the caller has, e.g. gzip, then aws-chunked: RFC 9110 wants
# the encodings in the order they were applied, and this one is the
# outermost, applied to the already-gzipped data (as botocore does)
my @encodings = grep { length && lc($_) ne 'aws-chunked' }
map { _trim($_) } split m{,}, $h->{'content-encoding'} // '';
$h->{'content-encoding'} = join ',', @encodings, 'aws-chunked';
}
else {
$payload_hash = _payload_hash(\%args);
}
$payload_hash = _agree($payload_hash, $given, sha256_hex(''));
$h->{'x-amz-content-sha256'} = $payload_hash
if $self->payload_header || $streaming || defined $given || !_is_sha256($payload_hash);
my %extra = $self->_auth->extra_fields;
$h->{lc $_} = $extra{$_} for keys %extra;
_check_header_values($h); # before they end up in the canonical request
my @signed = _signed_headers($h, $args{signed_headers}
// [grep { ! UNSIGNED->{$_} } keys $h->%*], $streaming ? 'content-encoding' : ());
my $r = $self->_signed($method, $path, _canonical_query($query), $h,
\@signed, $payload_hash, $amzdate, $scope);
my $credential = $self->_auth->credential_id . "/$scope";
$r->{authorization} = $self->algorithm . " Credential=$credential, "
. "SignedHeaders=$r->{signed_headers}, Signature=$r->{signature}";
_check_header_value(authorization => $r->{authorization});
$r->{headers} = {$h->%*, authorization => $r->{authorization}};
$r->{chunker} = AWS::Signature::V4::Chunker->new(
($unsigned_chunks ? () : (key => $self->_auth->signing_key($scope))),
signed => !$unsigned_chunks, amzdate => $amzdate,
scope => $scope, previous => $r->{signature},
expected => $args{decoded_content_length},
checksum => $args{checksum}, trailer_names => \@trailer_names,
) if $streaming;
return $r;
}
# presign(method => 'GET', url => ..., expires => 3600, time => ...)
# Returns a hashref:
# url: the URL, with the signature in its query string
# headers: headers that the client must send with it (Host, ...)
# signature, canonical_request, string_to_sign, signed_headers, scope
sub presign ($self, @args) {
my %args = _args(presign => @args);
my $method = _method($args{method} // 'GET');
my ($h, $path, $query, $amzdate, $scope) = $self->_request(\%args);
( run in 0.615 second using v1.01-cache-2.11-cpan-85d3896f969 )