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.
- checksums.yaml +5 -5
- data/.gitignore +2 -1
- data/.hound.yml +2 -0
- data/.rspec +1 -0
- data/.rubocop.yml +13 -0
- data/.ruby-version +1 -0
- data/.travis.yml +21 -2
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +2 -0
- data/{LICENSE.txt → LICENSE} +6 -6
- data/README.md +209 -2
- data/Rakefile +3 -3
- data/alexa_verifier.gemspec +21 -17
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/lib/alexa_verifier.rb +50 -113
- data/lib/alexa_verifier/base_error.rb +4 -0
- data/lib/alexa_verifier/certificate_store.rb +98 -0
- data/lib/alexa_verifier/configuration.rb +53 -0
- data/lib/alexa_verifier/invalid_certificate_error.rb +8 -0
- data/lib/alexa_verifier/invalid_certificate_u_r_i_error.rb +31 -0
- data/lib/alexa_verifier/invalid_request_error.rb +8 -0
- data/lib/alexa_verifier/verifier.rb +125 -0
- data/lib/alexa_verifier/verifier/certificate_u_r_i_verifier.rb +93 -0
- data/lib/alexa_verifier/verifier/certificate_verifier.rb +99 -0
- data/lib/alexa_verifier/version.rb +3 -0
- metadata +92 -31
data/bin/console
ADDED
@@ -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__)
|
data/bin/setup
ADDED
data/lib/alexa_verifier.rb
CHANGED
@@ -1,122 +1,59 @@
|
|
1
|
-
require '
|
2
|
-
require '
|
3
|
-
require '
|
4
|
-
require '
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
end
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
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
|
-
|
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
|
-
|
89
|
-
|
90
|
-
|
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
|
-
|
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
|
-
|
115
|
-
|
116
|
-
|
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,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,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,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
|