alexa_verifier 0.1.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'bundler/setup'
4
+ require 'alexa_verifier'
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require 'irb'
14
+ IRB.start(__FILE__)
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -1,122 +1,59 @@
1
- require 'net/http'
2
- require 'openssl'
3
- require 'base64'
4
- require 'time'
5
- require 'json'
6
-
7
- class AlexaVerifier
8
- VERSION = '0.1.0'
9
-
10
- class VerificationError < StandardError; end
11
-
12
- DEFAULT_TIMESTAMP_TOLERANCE = 150
13
-
14
- VALID_CERT_HOSTNAME = 's3.amazonaws.com'
15
- VALID_CERT_PATH_START = '/echo.api/'
16
- VALID_CERT_PORT = 443
17
-
18
- class Builder
19
- attr_accessor :verify_signatures, :verify_timestamps, :timestamp_tolerance
20
-
21
- def initialize
22
- @verify_signatures = true
23
- @verify_timestamps = true
24
- @timestamp_tolerance = DEFAULT_TIMESTAMP_TOLERANCE
25
- end
26
-
27
- def create
28
- AlexaVerifier.new(verify_signatures, verify_timestamps, timestamp_tolerance)
29
- end
30
- end
31
-
32
- def self.build(&block)
33
- builder = Builder.new
34
- block.call(builder)
35
- builder.create
36
- end
37
-
38
- def initialize(verify_signatures = true, verify_timestamps = true, timestamp_tolerance = DEFAULT_TIMESTAMP_TOLERANCE)
39
- @cert_cache = {}
40
- @verify_signatures = verify_signatures
41
- @verify_timestamps = verify_timestamps
42
- @timestamp_tolerance = timestamp_tolerance
43
- end
44
-
45
- def verify!(cert_url, signature, request)
46
- verify_timestamp!(request) if @verify_timestamps
47
-
48
- if @verify_signatures
49
- x509_cert = cert(cert_url)
50
- public_key = x509_cert.public_key
51
-
52
- unless public_key.verify(hash_type, Base64.decode64(signature), request)
53
- raise VerificationError.new, 'Signature does not match!'
54
- end
55
- end
56
-
57
- true
58
- end
59
-
60
- private
61
-
62
- def verify_timestamp!(request)
63
- request_json = JSON.parse(request)
64
-
65
- if request_json['request'].nil? or request_json['request']['timestamp'].nil?
66
- raise VerificationError.new, 'Timestamp field not present in request'
67
- end
68
-
69
- unless Time.parse(request_json['request']['timestamp']) >= (Time.now - @timestamp_tolerance)
70
- raise VerificationError.new, "Request is from more than #{@timestamp_tolerance} seconds ago"
71
- end
72
- end
73
-
74
- def hash_type
75
- OpenSSL::Digest::SHA1.new
76
- end
77
-
78
- def cert(cert_url)
79
- if @cert_cache[cert_url]
80
- @cert_cache[cert_url]
1
+ require 'alexa_verifier/certificate_store'
2
+ require 'alexa_verifier/configuration'
3
+ require 'alexa_verifier/verifier'
4
+ require 'alexa_verifier/version'
5
+
6
+ # Errors
7
+ require 'alexa_verifier/base_error'
8
+ require 'alexa_verifier/invalid_certificate_error'
9
+ require 'alexa_verifier/invalid_certificate_u_r_i_error'
10
+ require 'alexa_verifier/invalid_request_error'
11
+
12
+ # Verify that HTTP requests sent to an Alexa skill are sent from Amazon
13
+ # @since 0.1.0
14
+ module AlexaVerifier
15
+ REQUEST_THRESHOLD = 150 # Requests must be received within X seconds
16
+
17
+ class << self
18
+ attr_reader :verifier
19
+
20
+ # Returns our configuration object.
21
+ #
22
+ # @return [AlexaVerifier::Configuration] our configuration object
23
+ def configuration
24
+ verifier.configuration
25
+ end
26
+
27
+ # Sets a new configuration object.
28
+ #
29
+ # @param [AlexaVerifier::Configuration] configuration new configuration object
30
+ # @return [AlexaVerifier::Configuration] configuration object
31
+ def configuration=(configuration)
32
+ verifier.configuration = configuration
33
+ end
34
+
35
+ # Delegate all methods to the verifier object, essentially making the
36
+ # module object behave like a {Verifier}.
37
+ def method_missing(m, *args, &block)
38
+ if verifier.respond_to?(m)
39
+ verifier.send(m, *args, &block)
81
40
  else
82
- cert_uri = URI.parse(cert_url)
83
- validate_cert_uri!(cert_uri)
84
- @cert_cache[cert_url] = OpenSSL::X509::Certificate.new(download_cert(cert_uri))
41
+ super
85
42
  end
86
43
  end
87
44
 
88
- def download_cert(uri)
89
- http = Net::HTTP.new(uri.host, uri.port)
90
- http.use_ssl = true
91
- http.verify_mode = OpenSSL::SSL::VERIFY_PEER
92
- http.start
93
-
94
- response = http.request(Net::HTTP::Get.new(uri.request_uri))
95
-
96
- http.finish
97
-
98
- if response.code == '200'
99
- response.body
100
- else
101
- raise VerificationError, "Failed to download certificate at: #{uri}. Response code: #{response.code}, error: #{response.body}"
102
- end
45
+ # Delegating +respond_to+ to the {Verifier}.
46
+ def respond_to_missing?(m, include_private = false)
47
+ verifier.respond_to?(m) || super
103
48
  end
104
49
 
105
- def validate_cert_uri!(cert_uri)
106
- unless cert_uri.scheme == 'https'
107
- raise VerificationError, "Certificate URI MUST be https: #{cert_uri}"
108
- end
109
-
110
- unless cert_uri.port == VALID_CERT_PORT
111
- raise VerificationError, "Certificate URI port MUST be #{VALID_CERT_PORT}, was: #{cert_uri.port}"
112
- end
50
+ private
113
51
 
114
- unless cert_uri.host == VALID_CERT_HOSTNAME
115
- raise VerificationError, "Certificate URI hostname must be #{VALID_CERT_HOSTNAME}: #{cert_uri}"
116
- end
117
-
118
- unless cert_uri.request_uri.start_with?(VALID_CERT_PATH_START)
119
- raise VerificationError, "Certificate URI path must start with #{VALID_CERT_PATH_START}: #{cert_uri}"
120
- end
52
+ # Initialize a new instance of our Verifier to hold global configurations.
53
+ def initialize_verifier
54
+ @verifier = AlexaVerifier::Verifier.new
121
55
  end
56
+ end
57
+
58
+ initialize_verifier
122
59
  end
@@ -0,0 +1,4 @@
1
+ module AlexaVerifier
2
+ class BaseError < StandardError
3
+ end
4
+ end
@@ -0,0 +1,98 @@
1
+ module AlexaVerifier
2
+ # A module used to download, cache and serve certificates from our requests.
3
+ # @since 0.1
4
+ module CertificateStore
5
+ CERTIFICATE_CACHE_TIME = 1800 # 30 minutes
6
+ CERTIFICATE_SEPARATOR = '-----BEGIN CERTIFICATE-----'.freeze
7
+
8
+ class << self
9
+ # Given a certificate uri, either download the certificate and chain, or
10
+ # load them from our certificate store.
11
+ #
12
+ # @param [String] uri the uri of our certificate
13
+ # @return [OpenSSL::X509::Certificate, Array<OpenSSL::X509::Certificate>] our certificate file and chain
14
+ def fetch(uri)
15
+ store
16
+
17
+ if cache_valid?(@store[uri])
18
+ certificate = @store[uri][:certificate]
19
+ chain = @store[uri][:chain]
20
+ else
21
+ chain = generate_certificate_chain_from_data(download_certificate(uri))
22
+ certificate = chain.delete_at(0)
23
+
24
+ @store[uri] = { timestamp: Time.now, certificate: certificate, chain: chain }
25
+ end
26
+
27
+ [certificate, chain]
28
+ end
29
+
30
+ # Given a certificate uri, remove the certificate from our store.
31
+ #
32
+ # @param [String] uri the uri of our certificate
33
+ # @return [nil|Hash] returns nil if the certificate was not in the store,
34
+ # or a Hash representing the deleted certificate
35
+ def delete(uri)
36
+ store
37
+
38
+ @store.delete(uri)
39
+ end
40
+
41
+ # Returns a copy of our certificate store
42
+ #
43
+ # @return [Hash] returns our certificate store
44
+ def store
45
+ @store ||= {}
46
+ end
47
+
48
+ private
49
+
50
+ # Given a certificate entry from our store, tell us if the cache is still valid
51
+ #
52
+ # @param [Hash] certificate_entry the entry we are checking
53
+ # @return [Boolean] is the certificate cache valid?
54
+ def cache_valid?(certificate_entry)
55
+ return false if certificate_entry.nil?
56
+
57
+ (Time.now <= (certificate_entry[:timestamp] + CERTIFICATE_CACHE_TIME))
58
+ end
59
+
60
+ # Given a certificate uri, download it and return the certificate data
61
+ #
62
+ # @param [String] uri the uri of our certificates
63
+ # @return [String] certificate data
64
+ def download_certificate(uri)
65
+ certificate_uri = URI.parse(uri)
66
+
67
+ certificate_data = nil
68
+
69
+ Net::HTTP.start(certificate_uri.host, certificate_uri.port, use_ssl: true) do |http|
70
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
71
+
72
+ response = http.request(Net::HTTP::Get.new(certificate_uri))
73
+
74
+ raise AlexaVerifier::InvalidCertificateError, "Unable to download certificate from #{certificate_uri} - Got #{response.code} status code" unless response.is_a? Net::HTTPOK
75
+
76
+ certificate_data = response.body
77
+ end
78
+
79
+ certificate_data
80
+ end
81
+
82
+ # Given a string of certificate data, which may contain one or more certificates,
83
+ # convert it into an array of certificate object representing the full chain.
84
+ #
85
+ # @param [String] certificate_data the certificate data we should build our chain from
86
+ # @return [Array<OpenSSL::X509::Certificate>] an array of certificate objects representing our chain
87
+ def generate_certificate_chain_from_data(certificate_data)
88
+ split_data = certificate_data.split(CERTIFICATE_SEPARATOR)
89
+
90
+ # Remove any empty string artifacts
91
+ split_data.reject! { |data| data.strip.empty? }
92
+
93
+ # Convert our array of split out certificate data strings, into an array of certificate objects
94
+ split_data.map { |data| OpenSSL::X509::Certificate.new(CERTIFICATE_SEPARATOR + data) }
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,53 @@
1
+ module AlexaVerifier
2
+ # Stores our configuration information
3
+ # @since 0.2.0
4
+ class Configuration
5
+ attr_accessor :enabled, :verify_uri, :verify_timeliness, :verify_certificate, :verify_signature
6
+
7
+ # Create a new instance of our configuration object that has all of our settings enabled
8
+ def initialize
9
+ @enabled = true
10
+ @verify_uri = true
11
+ @verify_timeliness = true
12
+ @verify_certificate = true
13
+ @verify_signature = true
14
+ end
15
+
16
+ # Is AlexaVerifier enabled?
17
+ #
18
+ # This setting overrides all other settings
19
+ #
20
+ # @return [Boolean]
21
+ def enabled?
22
+ @enabled
23
+ end
24
+
25
+ # Should we verify the certificate URI?
26
+ #
27
+ # @return [Boolean]
28
+ def verify_uri?
29
+ @enabled ? @verify_uri : @enabled
30
+ end
31
+
32
+ # Should we verify the request's timeliness?
33
+ #
34
+ # @return [Boolean]
35
+ def verify_timeliness?
36
+ @enabled ? @verify_timeliness : @enabled
37
+ end
38
+
39
+ # Should we verify that the certificate is 'valid'?
40
+ #
41
+ # @return [Boolean]
42
+ def verify_certificate?
43
+ @enabled ? @verify_certificate : @enabled
44
+ end
45
+
46
+ # Should we verify that the request was signed with our certificate?
47
+ #
48
+ # @return [Boolean]
49
+ def verify_signature?
50
+ @enabled ? @verify_signature : @enabled
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,8 @@
1
+ module AlexaVerifier
2
+ # An error that is raised when the certificate referenced from a request is
3
+ # invalid.
4
+ #
5
+ # @since 0.1
6
+ class InvalidCertificateError < AlexaVerifier::BaseError
7
+ end
8
+ end
@@ -0,0 +1,31 @@
1
+ module AlexaVerifier
2
+ # An error that is raised when the certificate URI from a request is invalid.
3
+ # @since 0.1
4
+ class InvalidCertificateURIError < AlexaVerifier::BaseError
5
+ # Create a new instance of our InvalidCertificateURIError
6
+ #
7
+ # @param [String] message the main message we want to include
8
+ # @param [String] value an optional value used when creating a message.
9
+ #
10
+ # @example Error without a value
11
+ # AlexaVerifier::InvalidCertificateURIError.new(
12
+ # 'No URI Passed'
13
+ # ) #=> #<AlexaVerifier::InvalidCertificateURIError
14
+ # @message="Invalid certificate URI : No URI Passed.">
15
+ #
16
+ # @example Error with a valuex
17
+ # AlexaVerifier::InvalidCertificateURIError.new(
18
+ # "Expected 'a'",
19
+ # 'b'
20
+ # ) #=> #<AlexaVerifier::InvalidCertificateURIError
21
+ # @message="Invalid certificate URI : Expected 'a'. Got: 'b'.">
22
+ #
23
+ # @return [AlexaVerifier::InvalidCertificateURIError] a new instance
24
+ def initialize(message, value = nil)
25
+ error_message = "Invalid certificate URI : #{message}."
26
+ error_message = "#{error_message} Got: '#{value}'." if value
27
+
28
+ super(error_message)
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,8 @@
1
+ module AlexaVerifier
2
+ # An error that is raised when the certificate referenced from a request is
3
+ # invalid.
4
+ #
5
+ # @since 0.1
6
+ class InvalidRequestError < AlexaVerifier::BaseError
7
+ end
8
+ end
@@ -0,0 +1,125 @@
1
+ require_relative 'verifier/certificate_verifier'
2
+ require_relative 'verifier/certificate_u_r_i_verifier'
3
+
4
+ module AlexaVerifier
5
+ # A namespace for all of our verifiers to live under
6
+ # @since 0.1
7
+ class Verifier
8
+ attr_accessor :configuration
9
+
10
+ # Create a new AlexaVerifier::Verifier object
11
+ #
12
+ # @yield the configuration block
13
+ # @yieldparam config [AlexaVerifier::Configuration] the configuration object
14
+ def initialize
15
+ @configuration = AlexaVerifier::Configuration.new
16
+
17
+ yield @configuration if block_given?
18
+ end
19
+
20
+ # Validate a request object from Rack.
21
+ # Raise an error if it is not valid.
22
+ #
23
+ # @param [Rack::Request::Env] request a Rack HTTP Request
24
+ #
25
+ # @raise [AlexaVerifier::InvalidCertificateURIError]
26
+ # there was a problem validating the certificate URI from your request
27
+ #
28
+ # @return [nil] will always return nil
29
+ def valid!(request)
30
+ signature_certificate_url = request.env['HTTP_SIGNATURECERTCHAINURL']
31
+
32
+ AlexaVerifier::Verifier::CertificateURIVerifier.valid!(signature_certificate_url) if @configuration.verify_uri?
33
+
34
+ raw_body = request.body.read
35
+ request.body && request.body.rewind # call the rewind method if it exists (handles Sinatra specifically)
36
+
37
+ check_that_request_is_timely(raw_body) if @configuration.verify_timeliness?
38
+
39
+ check_that_request_is_valid(signature_certificate_url, request, raw_body)
40
+
41
+ true
42
+ end
43
+
44
+ # Validate a request object from Rack.
45
+ # Return a boolean.
46
+ #
47
+ # @param [Rack::Request::Env] request a Rack HTTP Request
48
+ # @return [Boolean] is the request valid?
49
+ def valid?(request)
50
+ begin
51
+ valid!(request)
52
+ rescue AlexaVerifier::BaseError => e
53
+ puts e
54
+
55
+ return false
56
+ end
57
+
58
+ true
59
+ end
60
+
61
+ # Used to configure AlexaVerifier.
62
+ #
63
+ # @example
64
+ # AlexaVerifier.configure do |c|
65
+ # c.some_config_option = true
66
+ # end
67
+ #
68
+ # @yield the configuration block
69
+ # @yieldparam config [AlexaVerifier::Configuration] the configuration object
70
+ def configure
71
+ yield @configuration
72
+ end
73
+
74
+ private
75
+
76
+ # Prevent replays of requests by checking that they are timely.
77
+ #
78
+ # @param [String] raw_body the raw body of our https request
79
+ # @raise [AlexaVerifier::InvalidRequestError] raised when the timestamp is not timely, or is not set
80
+ def check_that_request_is_timely(raw_body)
81
+ request_json = JSON.parse(raw_body)
82
+
83
+ raise AlexaVerifier::InvalidRequestError, 'Timestamp field not present in request' if request_json.fetch('request', {}).fetch('timestamp', nil).nil?
84
+
85
+ request_is_timely = (Time.parse(request_json['request']['timestamp'].to_s) >= (Time.now - REQUEST_THRESHOLD))
86
+ raise AlexaVerifier::InvalidRequestError, "Request is from more than #{REQUEST_THRESHOLD} seconds ago" unless request_is_timely
87
+ end
88
+
89
+ # Check that our request is valid.
90
+ #
91
+ # @param [String] signature_certificate_url the url for our signing certificate
92
+ # @param [Rack::Request::Env] request the request object
93
+ # @param [String] raw_body the raw body of our https request
94
+ def check_that_request_is_valid(signature_certificate_url, request, raw_body)
95
+ certificate, chain = AlexaVerifier::CertificateStore.fetch(signature_certificate_url) if @configuration.verify_certificate? || @configuration.verify_signature?
96
+
97
+ begin
98
+ AlexaVerifier::Verifier::CertificateVerifier.valid!(certificate, chain) if @configuration.verify_certificate?
99
+
100
+ check_that_request_was_signed(certificate.public_key, request, raw_body) if @configuration.verify_signature?
101
+ rescue AlexaVerifier::InvalidCertificateError, AlexaVerifier::InvalidRequestError => error
102
+ # We don't want to cache a certificate that fails our checks as it could lock us out of valid requests for the cache length
103
+ AlexaVerifier::CertificateStore.delete(signature_certificate_url)
104
+
105
+ raise error
106
+ end
107
+ end
108
+
109
+ # Check that our request was signed by a given public key.
110
+ #
111
+ # @param [OpenSSL::PKey::PKey] certificate_public_key the public key we are checking
112
+ # @param [Rack::Request::Env] request the request object we are checking
113
+ # @param [String] raw_body the raw body of our https request
114
+ # @raise [AlexaVerifier::InvalidRequestError] raised if our signature does not match the certificate provided
115
+ def check_that_request_was_signed(certificate_public_key, request, raw_body)
116
+ signed_by_certificate = certificate_public_key.verify(
117
+ OpenSSL::Digest::SHA1.new,
118
+ Base64.decode64(request.env['HTTP_SIGNATURE']),
119
+ raw_body
120
+ )
121
+
122
+ raise AlexaVerifier::InvalidRequestError, 'Signature does not match certificate provided' unless signed_by_certificate
123
+ end
124
+ end
125
+ end