Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Default signature version changed #85

Open
wants to merge 16 commits into
base: master
Choose a base branch
from
Open
4 changes: 4 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
0.8.1:
* Bugs fixed for Aws signature V4 version.
* Default AWS signature version has been updated to v4.

0.7.1:
* Bugfix in sig version v4 detection

Expand Down
4 changes: 3 additions & 1 deletion README.erb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
= AWS::SES

{<img src="https://badge.fury.io/rb/aws-ses.svg" alt="http://rubygems.org">}[http://badge.fury.io/rb/aws-ses]

<%= docs_for['AWS::SES'] %>

== Send E-mail
Expand All @@ -20,7 +22,7 @@ This gem is compatible with Rails >= 3.0.0 and Ruby 2.3.x

To use, first add the gem to your Gemfile:

gem "aws-ses", "~> 0.7.1", :require => 'aws/ses'
gem "aws-ses-v4", "~> 0.8.1", :require => 'aws/ses'

== For Rails 3.x

Expand Down
4 changes: 3 additions & 1 deletion README.rdoc
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
= AWS::SES

{<img src="https://badge.fury.io/rb/aws-ses-v4.svg" alt="http://rubygems.org">}[http://badge.fury.io/rb/aws-ses-v4]

AWS::SES is a Ruby library for Amazon's Simple Email Service's REST API (http://aws.amazon.com/ses).

== Getting started
Expand Down Expand Up @@ -141,7 +143,7 @@ This gem is compatible with Rails >= 3.0.0 and Ruby 2.3.x

To use, first add the gem to your Gemfile:

gem "aws-ses", "~> 0.7.0", :require => 'aws/ses'
gem "aws-ses-v4", "~> 0.8.1", :require => 'aws/ses'

== For Rails 3.x

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.7.1
0.8.1
8 changes: 4 additions & 4 deletions aws-ses.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@
# stub: aws-ses 0.7.1 ruby lib

Gem::Specification.new do |s|
s.name = "aws-ses".freeze
s.version = "0.7.1"
s.name = "aws-ses-v4".freeze
s.version = "0.8.1"

s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version=
s.require_paths = ["lib".freeze]
s.authors = ["Drew Blas".freeze, "Marcel Molina Jr.".freeze]
s.date = "2020-09-30"
s.date = "2021-04-10"
s.description = "Client library for Amazon's Simple Email Service's REST API".freeze
s.email = "[email protected]".freeze
s.extra_rdoc_files = [
Expand Down Expand Up @@ -52,7 +52,7 @@ Gem::Specification.new do |s|
"test/response_test.rb",
"test/send_email_test.rb"
]
s.homepage = "http://github.com/drewblas/aws-ses".freeze
s.homepage = "http://github.com/sertangulveren/aws-ses".freeze
s.licenses = ["MIT".freeze]
s.rubygems_version = "2.5.2.3".freeze
s.summary = "Client library for Amazon's Simple Email Service's REST API".freeze
Expand Down
125 changes: 70 additions & 55 deletions lib/aws/ses/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,19 +32,21 @@ module AWS #:nodoc:
#

module SES

API_VERSION = '2010-12-01'

DEFAULT_REGION = 'us-east-1'

SERVICE = 'ec2'
SERVICE = 'ses'

DEFAULT_HOST = 'email.us-east-1.amazonaws.com'

DEFAULT_MESSAGE_ID_DOMAIN = 'email.amazonses.com'

USER_AGENT = 'github-aws-ses-ruby-gem'


DEFAULT_SIGNATURE_VERSION = 4

# Encodes the given string with the secret_access_key by taking the
# hmac-sha1 sum, and then base64 encoding it. Optionally, it will also
# url encode the result of that to protect the string if it's going to
Expand All @@ -66,9 +68,9 @@ def SES.encode(secret_access_key, str, urlencode=true)
return b64_hmac
end
end

# Generates the HTTP Header String that Amazon looks for
#
#
# @param [String] key the AWS Access Key ID
# @param [String] alg the algorithm used for the signature
# @param [String] sig the signature itself
Expand All @@ -81,11 +83,12 @@ def SES.authorization_header_v4(credential, signed_headers, signature)
end

# AWS::SES::Base is the abstract super class of all classes who make requests against SES
class Base
class Base
include SendEmail
include Info

attr_reader :use_ssl, :server, :proxy_server, :port, :message_id_domain, :signature_version, :region

attr_reader :use_ssl, :server, :proxy_server, :port, :message_id_domain, :signature_version, :region,
:action, :action_time, :query
attr_accessor :settings

# @option options [String] :access_key_id ("") The user's AWS Access Key ID
Expand All @@ -107,10 +110,11 @@ def initialize( options = {} )
:path => "/",
:user_agent => USER_AGENT,
:proxy_server => nil,
:region => DEFAULT_REGION
}.merge(options)
:region => DEFAULT_REGION,
:signature_version => DEFAULT_SIGNATURE_VERSION
}.merge(options)

@signature_version = options[:signature_version] || 2
@signature_version = options[:signature_version]
@server = options[:server]
@message_id_domain = options[:message_id_domain]
@proxy_server = options[:proxy_server]
Expand All @@ -125,6 +129,7 @@ def initialize( options = {} )
raise ArgumentError, "No :use_ssl value provided" if options[:use_ssl].nil?
raise ArgumentError, "Invalid :use_ssl value provided, only 'true' or 'false' allowed" unless options[:use_ssl] == true || options[:use_ssl] == false
raise ArgumentError, "No :server provided" if options[:server].nil? || options[:server].empty?
raise ArgumentError, ":signature_version must be 2 or 4" unless [2, 4].include?(options[:signature_version])

if options[:port]
# user-specified port
Expand All @@ -150,59 +155,66 @@ def initialize( options = {} )

@http.use_ssl = @use_ssl
end

def connection
@http
end
# Make the connection to AWS passing in our request.

# Make the connection to AWS passing in our request.
# allow us to have a one line call in each method which will do all of the work
# in making the actual request to AWS.
def request(action, params = {})
@action = action
# Use a copy so that we don't modify the caller's Hash, remove any keys that have nil or empty values
params = params.reject { |key, value| value.nil? or value.empty?}
timestamp = Time.now.getutc
params = params.reject { |_, value| value.nil? or value.empty?}

@action_time = Time.now.getutc

params.merge!( {"Action" => action,
"SignatureVersion" => signature_version.to_s,
"SignatureMethod" => 'HmacSHA256',
"AWSAccessKeyId" => @access_key_id,
"Version" => API_VERSION,
"Timestamp" => timestamp.iso8601 } )
"Timestamp" => action_time.iso8601 } )

query = params.sort.collect do |param|
@query = params.sort.collect do |param|
CGI::escape(param[0]) + "=" + CGI::escape(param[1])
end.join("&")
response = connection.post(@path, query, get_req_headers)

req = {}

req['X-Amzn-Authorization'] = get_aws_auth_param(timestamp.httpdate, @secret_access_key, action, signature_version)
req['Date'] = timestamp.httpdate
req['User-Agent'] = @user_agent

response = connection.post(@path, query, req)

response_class = AWS::SES.const_get( "#{action}Response" )
result = response_class.new(action, response)

if result.error?
raise ResponseError.new(result)
end

result
end

# Set the Authorization header using AWS signed header authentication
def get_aws_auth_param(timestamp, secret_access_key, action = '', signature_version = 2)
raise(ArgumentError, "signature_version must be `2` or `4`") unless signature_version == 2 || signature_version == 4
encoded_canonical = SES.encode(secret_access_key, timestamp, false)

def get_req_headers
headers = {}
if signature_version == 4
SES.authorization_header_v4(sig_v4_auth_credential, sig_v4_auth_signed_headers, sig_v4_auth_signature(action))
headers['host'] = @server
headers['authorization'] = get_aws_auth_header_v4
headers['x-amz-date'] = amzdate
headers['user-agent'] = @user_agent
else
SES.authorization_header(@access_key_id, 'HmacSHA256', encoded_canonical)
headers['x-amzn-authorization'] = get_aws_auth_header_v2
headers['date'] = action_time.httpdate
headers['user-agent'] = @user_agent
end
headers
end

# Set the Authorization header using AWS signed header authentication
def get_aws_auth_header_v2
encoded_canonical = SES.encode(@secret_access_key, httpdate, false)
SES.authorization_header(@access_key_id, 'HmacSHA256', encoded_canonical)
end

def get_aws_auth_header_v4
SES.authorization_header_v4(sig_v4_auth_credential, sig_v4_auth_signed_headers, sig_v4_auth_signature)
end

private
Expand All @@ -219,45 +231,48 @@ def credential_scope
datestamp + '/' + region + '/' + SERVICE + '/' + 'aws4_request'
end

def string_to_sign(for_action)
"AWS4-HMAC-SHA256\n" + amzdate + "\n" + credential_scope + "\n" + Digest::SHA256.hexdigest(canonical_request(for_action).encode('utf-8').b)
def string_to_sign
"AWS4-HMAC-SHA256\n" + amzdate + "\n" + credential_scope + "\n" + Digest::SHA256.hexdigest(canonical_request.encode('utf-8').b)
end


def amzdate
Time.now.utc.strftime('%Y%m%dT%H%M%SZ')
@action_time ||= Time.now.getutc
action_time.strftime('%Y%m%dT%H%M%SZ')
end

def datestamp
Time.now.utc.strftime('%Y%m%d')
@action_time ||= Time.now.getutc
action_time.strftime('%Y%m%d')
end

def canonical_request(for_action)
"GET" + "\n" + "/" + "\n" + canonical_querystring(for_action) + "\n" + canonical_headers + "\n" + sig_v4_auth_signed_headers + "\n" + payload_hash
def httpdate
@action_time ||= Time.now.getutc.httpdate
end

def canonical_querystring(action)
"Action=#{action}&Version=2013-10-15"
def canonical_request
"POST" + "\n" + "/" + "\n" + canonical_querystring + "\n" + canonical_headers + "\n" + sig_v4_auth_signed_headers + "\n" + payload_hash
end

def canonical_querystring
signature_version == 2 ? "Action=#{action}&Version=2013-10-15" : ''
end

def canonical_headers
'host:' + server + "\n" + 'x-amz-date:' + amzdate + "\n"
end

def payload_hash
Digest::SHA256.hexdigest(''.encode('utf-8'))
Digest::SHA256.hexdigest(query.to_s.encode('utf-8'))
end

def sig_v4_auth_signature(for_action)
signing_key = getSignatureKey(@secret_access_key, datestamp, region, SERVICE)

OpenSSL::HMAC.hexdigest("SHA256", signing_key, string_to_sign(for_action).encode('utf-8'))
def sig_v4_auth_signature
OpenSSL::HMAC.hexdigest("SHA256", getSignatureKey, string_to_sign.encode('utf-8'))
end

def getSignatureKey(key, dateStamp, regionName, serviceName)
kDate = sign(('AWS4' + key).encode('utf-8'), dateStamp)
kRegion = sign(kDate, regionName)
kService = sign(kRegion, serviceName)
def getSignatureKey
kDate = sign(('AWS4' + @secret_access_key).encode('utf-8'), datestamp)
kRegion = sign(kDate, region)
kService = sign(kRegion, SERVICE)
kSigning = sign(kService, 'aws4_request')

kSigning
Expand Down
21 changes: 10 additions & 11 deletions test/base_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ def test_connection_established

assert_not_nil instance.instance_variable_get("@http")
end

def test_failed_response
@base = generate_base
mock_connection(@base, {:code => 403, :body => %{
Expand Down Expand Up @@ -41,14 +41,16 @@ def test_failed_response
def test_ses_authorization_header_v2
aws_access_key_id = 'fake_aws_key_id'
aws_secret_access_key = 'fake_aws_access_key'
timestamp = Time.new(2020, 7, 2, 7, 17, 58, '+00:00')

time = Time.new(2021, 4, 8, 14, 07, 58, '+00:00')
::Timecop.freeze(time)
base = ::AWS::SES::Base.new(
access_key_id: aws_access_key_id,
secret_access_key: aws_secret_access_key
secret_access_key: aws_secret_access_key,
signature_version: 2
)

assert_equal 'AWS3-HTTPS AWSAccessKeyId=fake_aws_key_id, Algorithm=HmacSHA256, Signature=eHh/cPIJJUc1+RMCueAi50EPlYxkZNXMrxtGxjkBD1w=', base.get_aws_auth_param(timestamp.httpdate, aws_secret_access_key)
assert_equal 'AWS3-HTTPS AWSAccessKeyId=fake_aws_key_id, Algorithm=HmacSHA256, Signature=Yi984ReKUv7ckcrwvsBgzLlQX/1kyva74+SF+PlrwZ0=', base.get_aws_auth_header_v2
Timecop.return
end

def test_ses_authorization_header_v4
Expand All @@ -59,12 +61,11 @@ def test_ses_authorization_header_v4

base = ::AWS::SES::Base.new(
server: 'ec2.amazonaws.com',
signature_version: 4,
access_key_id: aws_access_key_id,
secret_access_key: aws_secret_access_key
)

assert_equal 'AWS4-HMAC-SHA256 Credential=fake_aws_key_id/20200702/us-east-1/ec2/aws4_request, SignedHeaders=host;x-amz-date, Signature=c0465b36efd110b14a1c6dcca3e105085ed2bfb2a3fd3b3586cc459326ab43aa', base.get_aws_auth_param(time.httpdate, aws_secret_access_key, 'DescribeRegions', 4)
assert_equal 'AWS4-HMAC-SHA256 Credential=fake_aws_key_id/20200702/us-east-1/ses/aws4_request, SignedHeaders=host;x-amz-date, Signature=fb25a4a91c6f5c17637c81744998ac664d16ce70982c5ca92c0a62199ef4e7d6', base.get_aws_auth_header_v4
Timecop.return
end

Expand All @@ -76,12 +77,11 @@ def test_ses_authorization_header_v4_changed_host

base = ::AWS::SES::Base.new(
server: 'email.us-east-1.amazonaws.com',
signature_version: 4,
access_key_id: aws_access_key_id,
secret_access_key: aws_secret_access_key
)

assert_equal 'AWS4-HMAC-SHA256 Credential=fake_aws_key_id/20200702/us-east-1/ec2/aws4_request, SignedHeaders=host;x-amz-date, Signature=b872601457070ab98e7038bdcd4dc1f5eab586ececf9908525474408b0740515', base.get_aws_auth_param(time.httpdate, aws_secret_access_key, 'DescribeRegions', 4)
assert_equal 'AWS4-HMAC-SHA256 Credential=fake_aws_key_id/20200702/us-east-1/ses/aws4_request, SignedHeaders=host;x-amz-date, Signature=c77a9d002342ac5b210c8f4a054712f9e00335ce9555c359ea8982acfa32db4c', base.get_aws_auth_header_v4
Timecop.return
end

Expand All @@ -93,13 +93,12 @@ def test_ses_authorization_header_v4_changed_region

base = ::AWS::SES::Base.new(
server: 'email.us-east-1.amazonaws.com',
signature_version: 4,
access_key_id: aws_access_key_id,
secret_access_key: aws_secret_access_key,
region: 'eu-west-1'
)

assert_not_equal 'AWS4-HMAC-SHA256 Credential=fake_aws_key_id/20200702/us-east-1/ec2/aws4_request, SignedHeaders=host;x-amz-date, Signature=b872601457070ab98e7038bdcd4dc1f5eab586ececf9908525474408b0740515', base.get_aws_auth_param(time.httpdate, aws_secret_access_key, 'DescribeRegions', 4)
assert_not_equal 'AWS4-HMAC-SHA256 Credential=fake_aws_key_id/20200702/us-east-1/ec2/aws4_request, SignedHeaders=host;x-amz-date, Signature=b872601457070ab98e7038bdcd4dc1f5eab586ececf9908525474408b0740515', base.get_aws_auth_header_v4
Timecop.return
end
end