Group
Extension

Net-Amazon-AlexaValidator/lib/Net/Amazon/AlexaValidator.pm

package Net::Amazon::AlexaValidator;

our $VERSION = "0.01";
$VERSION = eval $VERSION;

use Crypt::OpenSSL::RSA;
use Crypt::OpenSSL::VerifyX509;
use Crypt::OpenSSL::X509;
use DateTime::Format::ISO8601;
use DateTime::Format::x509;
use Digest::MD5 qw(md5_hex);
use Digest::SHA1 qw(sha1);
use Encode;
use JSON;
use LWP::Simple;
use MIME::Base64 qw(decode_base64);
use Moo;
use Try::Tiny;
use Types::Standard -types;
use URI;
use URI::Normalize qw( normalize_uri );

# ABSTRACT: Implements all security-related checks required for Amazon Alexa Skills

=head1 NAME

Net::Amazon::AlexaValidator - implements all security-related checks required for
Amazon Alexa Skills.

=head1 SYNOPSIS

  my $alexa_validator = Net::Amazon::AlexaValidator->new({
    application_id => 'my_application_id_from_amazon_dev_site',
    echo_domain    => 'DNS:echo-api.amazon.com',
    cert_dir       => '/tmp/',
    });
  my $request = $c->req; # Requires a L<Catalyst::Request> object
  my $ret = $alexa_validator->validate_request($request);

=head1 DESCRIPTION

Highlights of the validation include:

=over

=item *

Verifies the Signature Certificate URL. Amazon's requirements are listed here: L<https://developer.amazon.com/public/solutions/alexa/alexa-skills-kit/docs/developing-an-alexa-skill-as-a-web-service#h2_verify_sig_cert>

=item * 

Downloads the PEM-encoded X.509 certificate chain that Alexa used to sign the message as specified by the SignatureCertChainUrl header value on the request.

=item *

Validates that the signing certificate has not expired (examine both the Not Before and Not After dates).

=item *

Validates that the domain echo-api.amazon.com is present in the Subject Alternative Names (SANs) section of the signing certificate.

=item *

Validates that all certificates in the chain combine to create a chain of trust to a trusted root CA certificate.

=item *

Base64-decodes the Signature header value on the request to obtain the encrypted signature.

=item *

Uses the public key extracted from the signing certificate to decrypt the encrypted signature to produce the asserted hash value. Generates a SHA-1 hash value from the full HTTPS request body to produce the derived hash value, and compares the asserted hash value and derived hash values to ensure that they match.

=item *

Checks the request timestamp to ensure that the request is not an old request being sent as part of a "replay" attack.

=over

=back

=back

=head1 Configuration options

=over

=back

=head2 echo_domain

The echo domain that must be present in the Subject Alternative Names (SANs) section of the signing certificate

=cut

has 'echo_domain' => (
  is      => 'rw',
  isa     => Str,
  );

=head2 application_id

Application ID from your app's Amazon Alexa App settings

=cut

has 'application_id' => (
  is      => 'rw',
  isa     => Maybe[Str],
  );


=head2 cert_dir

Directory in which to store your Alexa certificate, once validated

=cut

has 'cert_dir' => (
  is      => 'rw',
  isa     => Maybe[Str],
  );

=head1 Subroutines

=over

=back

=head2 validate_request

Verifies this is a valid Amazon Alexa request. Checks things like application_id, certificates, timestamp.

returns { success, error_msg }

=cut

sub validate_request {
  my ($self, $request) = @_;


  my $raw_request;

  my $body_fh = $request->body;
  # Read in the raw request exactly as Amazon sent it (Catalyst will sometimes reorder the request fields)
  # request->body is an IO::Handle that can be read exactly like any filehandle. Grab all the data from that file.
  unless(seek $body_fh, 0, 0) {
    return { success => 0, error_msg => "Could not read catalyst request FH. Seek failed: $!" };
  }
  $raw_request = do { local $/; <$body_fh> }; # slurp the raw request

  # Validate application ID: must match the one we specified in the Alexa App settings
  my $alexa_msg = $request->body_data;
  unless($alexa_msg->{session}->{application}->{applicationId} eq $self->application_id) {
    return { success => 0, error_msg => "Invalid Application ID" };
  }

  # Validate timestamp: must be in the last 150 seconds.
  my $ts = $alexa_msg->{request}->{timestamp};
  my $msg_time = DateTime::Format::ISO8601->parse_datetime($ts);
  my $diff = DateTime->now() - $msg_time;
  unless($diff->seconds < 150) {
    return { success => 0, error_msg => "Invalid Amazon Alexa timestamp: $ts" };
  }

  # It's possible we've already downloaded and validated Amazon's request signature. If that's the case, we'll have
  # a file with the same md5-hashed name.
  my $cert_chain_url = $request->headers->header('signaturecertchainurl');
  my $primary_cert_filename = $self->cert_dir . md5_hex($cert_chain_url) . "_primary.pem";
  my $secondary_cert_filename = $self->cert_dir . md5_hex($cert_chain_url) . "_secondary.pem";

  my $decoded_cert;
  if (-f $primary_cert_filename && -f $secondary_cert_filename) {
    # If the file already exists, we've validated it during a previous Alexa interaction. Unless the $cert_chain_url
    # has changed (in which case, the file name changes), we can go ahead and consider it validated.
    # Primary certificate is already validated...just read it from file.
    $decoded_cert = Crypt::OpenSSL::X509->new_from_file( $primary_cert_filename );
  }
  else {
    # Validate the URI of the certificate chain
    my $keychain_invalid = $self->_invalid_keychain_uri($cert_chain_url);
    if($keychain_invalid) {
      return { success => 0, error_msg => sprintf("Invalid Amazon Alexa keychain URI (%s): %s", $cert_chain_url, $keychain_invalid) };
    }
    # URI of the certificate appears valid, so go ahead and download it
    my $cert = get($cert_chain_url);

    # $cert actually contains TWO certificates. Use the first one.
    my @two_certs = split(/(-----BEGIN CERTIFICATE-----)/, $cert);

    unless((scalar @two_certs) > 2) {
      return { success => 0, error_msg => "Invalid Amazon Certificate file" };
    }

    # Split the certificate up into two parts and save both to file.
    my $primary_cert   = $two_certs[1] . $two_certs[2]; # grab and use the primary certificate
    my $secondary_cert = $two_certs[3] . $two_certs[4]; # grab the secondary certificate
    open(my $primary_cert_fh, '>', $primary_cert_filename ) or
      return { success => 0, error_msg => "Could not open file $primary_cert_filename" };
    print $primary_cert_fh $primary_cert;
    close $primary_cert_fh;
    open(my $secondary_cert_fh, '>', $secondary_cert_filename ) or
      return { success => 0, error_msg => "Could not open file $secondary_cert_filename" };
    print $secondary_cert_fh $secondary_cert;
    close $secondary_cert_fh;

    $decoded_cert = Crypt::OpenSSL::X509->new_from_string( $primary_cert );

    # Ensure that the echo domain is present in the Subject Alternative Names (SANs) section of the signing certificate
    my $exts = $decoded_cert->extensions_by_name();
    my $san_ext = $exts->{subjectAltName};
    unless ($san_ext->to_string() eq $self->echo_domain) {
      unlink $primary_cert_filename;
      unlink $secondary_cert_filename;
      return { success => 0, error_msg => sprintf("Amazon Alexa certificate failed SANs validation: %s", $san_ext) };
    }

    # Ensure that all certificates in the chain combine to create a chain of trust to a trusted root CA certificate
    # Compare the asserted hash value and derived hash values to ensure that they match.
    my $openssl_verify = "openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt -untrusted $secondary_cert_filename $primary_cert_filename";
    my $valid;

    open my $check, "-|", $openssl_verify or ( log_error({user => 'ssiegal', message => "Cannot run OpenSSL"}) && return 0 );
    if (do { local $/; <$check> } =~ /\bOK\b/) {
      $valid = 1;
    }
    close $check or ( log_error({user => 'ssiegal', message => "Cannot run OpenSSL"}) && return 0 );

    unless($valid) {
      unlink $primary_cert_filename;
      unlink $secondary_cert_filename;
      return { success => 0, error_msg => "Amazon Alexa cert failed openssl verification" };
    }
  }

  # At this point we should have a decoded certificate.
  unless($decoded_cert) {
    unlink $primary_cert_filename;
    unlink $secondary_cert_filename;
    return { success => 0, error_msg => "Could not read primary certificate from file $primary_cert_filename" };
  }

  # Ensure that the signing certificate has not expired (examine both the Not Before and Not After dates)
  my $not_before = DateTime::Format::x509->parse_datetime($decoded_cert->notBefore);
  my $not_after  = DateTime::Format::x509->parse_datetime($decoded_cert->notAfter);
  my $now = DateTime->now();
  unless($now >= $not_before && $now <= $not_after) {
    unlink $primary_cert_filename;
    unlink $secondary_cert_filename;
    return { success => 0, error_msg => sprintf("Amazon Alexa certificate failed before/after timestamp validation (%s/%s)", $not_before, $not_after) };
  }

  # Once you have determined that the signing certificate is valid, extract the public key from it.
  my $pub_key = Crypt::OpenSSL::RSA->new_public_key($decoded_cert->pubkey());

  # Base64-decode the Signature header value on the request to obtain the encrypted signature.
  my $signature = decode_base64($request->headers->header('signature'));

  my $valid;
  try {
    $valid = $pub_key->verify($raw_request, $signature);
  } catch {
    return { success => 0, error_msg => "Amazon Alexa certificate failed public key validation: $_" };
  };

  unless($valid) {
    unlink $primary_cert_filename;
    unlink $secondary_cert_filename;
    return { success => 0, error_msg => "Amazon Alexa cert failed public key verification" };
  }

  # If we're here, we've passed all the checks. FULL SPEED AHEAD!
  return { success => 1, error_msg => "" };
}

# Validate keychain uri from Amazon is proper. undef indicates no errors found.
# Example URI: https://s3.amazonaws.com/echo.api/echo-api-cert-3.pem
sub _invalid_keychain_uri {
  my ($self, $keychain_uri) = @_;

  # Normalize the URI first
  my $uri = normalize_uri( $keychain_uri );

  my $error_msg = undef;

  unless(lc($uri->host) eq 's3.amazonaws.com') {
    $error_msg = 'The host for the Certificate provided in the header is invalid';
  }

  unless($uri->path =~ m{^/echo.api/}) {
    $error_msg = 'The URL path for the Certificate provided in the header is invalid';
  }

  unless(lc($uri->scheme) eq 'https') {
    $error_msg = 'The URL is using an unsupported scheme. Should be https';
  }

  if ($uri->port && $uri->port != 443) {
    $error_msg = 'The URL is using an unsupported https port';
  }

  return $error_msg;
}

1;


Powered by Groonga
Maintained by Kenichi Ishigaki <ishigaki@cpan.org>. If you find anything, submit it on GitHub.