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

[#78] more efficient sigv4 signing method #79

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 91 additions & 56 deletions lib/aws/ses/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,14 @@ module SES

DEFAULT_REGION = 'us-east-1'

SERVICE = 'ec2'
SERVICE = 'ses'

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

DEFAULT_MESSAGE_ID_DOMAIN = 'email.amazonses.com'

UNSIGNED_HEADERS = ['content-length', 'user-agent', 'authorization']

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

# Encodes the given string with the secret_access_key by taking the
Expand Down Expand Up @@ -85,7 +87,7 @@ 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, :datetime, :date, :headers, :query, :action
attr_accessor :settings

# @option options [String] :access_key_id ("") The user's AWS Access Key ID
Expand Down Expand Up @@ -125,6 +127,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 signature_version == 2 || signature_version == 4

if options[:port]
# user-specified port
Expand Down Expand Up @@ -160,31 +163,33 @@ def connection
# in making the actual request to AWS.
def request(action, params = {})
# Use a copy so that we don't modify the caller's Hash, remove any keys that have nil or empty values
@action = action
params = params.reject { |key, value| value.nil? or value.empty?}

timestamp = Time.now.getutc
timestamp = Time.now.utc
@datetime = timestamp.strftime("%Y%m%dT%H%M%SZ")
@date = @datetime[0, 8]

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

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

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
@headers = {}
@headers['host'] = @server
@headers['x-amz-date'] = @datetime
@headers['user-agent'] = @user_agent
@headers[@signature_version == 4 ? 'authorization' : 'x-amzn-authorization'] = gen_authorization(timestamp.httpdate)

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

response_class = AWS::SES.const_get( "#{action}Response" )
result = response_class.new(action, response)
response = connection.post(@path, @query, @headers)
response_class = AWS::SES.const_get( "#{@action}Response" )
result = response_class.new(@action, response)

if result.error?
raise ResponseError.new(result)
Expand All @@ -194,77 +199,107 @@ def request(action, params = {})
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 gen_authorization(timestamp)
if signature_version == 4
SES.authorization_header_v4(sig_v4_auth_credential, sig_v4_auth_signed_headers, sig_v4_auth_signature(action))
sigv4 = signature(date, region, SERVICE, string_to_sign)
SES.authorization_header_v4(credentials, signed_headers, sigv4)
else
encoded_canonical = SES.encode(@secret_access_key, timestamp, false)
SES.authorization_header(@access_key_id, 'HmacSHA256', encoded_canonical)
end
end

private

def sig_v4_auth_credential
@access_key_id + '/' + credential_scope
end

def sig_v4_auth_signed_headers
'host;x-amz-date'
end

def credential_scope
datestamp + '/' + region + '/' + SERVICE + '/' + 'aws4_request'
def canonical_uri
'/'
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 canonical_querystring
signature_version == 2 ? "Action=#{action}&Version=2013-10-15" : ''
end


def amzdate
Time.now.utc.strftime('%Y%m%dT%H%M%SZ')
def canonical_request
[
'POST',
canonical_uri,
canonical_querystring,
canonical_headers + "\n",
signed_headers,
sha256_hexdigest(@query),
].join("\n")
end

def datestamp
Time.now.utc.strftime('%Y%m%d')
def credential_scope
date + '/' + region + '/' + SERVICE + '/' + 'aws4_request'
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 string_to_sign
[
'AWS4-HMAC-SHA256',
datetime,
credential_scope,
sha256_hexdigest(canonical_request),
].join("\n")
end

def canonical_querystring(action)
"Action=#{action}&Version=2013-10-15"
def signed_headers
@headers.inject([]) do |signed_headers, (header, _)|
if UNSIGNED_HEADERS.include?(header)
signed_headers
else
signed_headers << header
end
end.sort.join(';')
end

def canonical_headers
'host:' + server + "\n" + 'x-amz-date:' + amzdate + "\n"
_headers = @headers.inject([]) do |hdrs, (k,v)|
if UNSIGNED_HEADERS.include?(k)
hdrs
else
hdrs << [k,v]
end
end
_headers = _headers.sort_by(&:first)
_headers.map{|k,v| "#{k}:#{canonical_header_value(v.to_s)}" }.join("\n")
end

def payload_hash
Digest::SHA256.hexdigest(''.encode('utf-8'))
def canonical_header_value(value)
value.match(/^".*"$/) ? value : value.gsub(/\s+/, ' ').strip
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 credentials
@access_key_id + '/' + credential_scope
end

def getSignatureKey(key, dateStamp, regionName, serviceName)
kDate = sign(('AWS4' + key).encode('utf-8'), dateStamp)
kRegion = sign(kDate, regionName)
kService = sign(kRegion, serviceName)
kSigning = sign(kService, 'aws4_request')
def signature(date, region, service, string_to_sign)
k_date = hmac("AWS4" + @secret_access_key, date)
k_region = hmac(k_date, region)
k_service = hmac(k_region, service)
k_credentials = hmac(k_service, 'aws4_request')
OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), k_credentials, string_to_sign)
end

kSigning
def hmac(key, value)
OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha256'), key, value)
end

def sign(key, msg)
OpenSSL::HMAC.digest("SHA256", key, msg.encode('utf-8'))
def sha256_hexdigest(value)
if (File === value || Tempfile === value) && !value.path.nil? && File.exist?(value.path)
OpenSSL::Digest::SHA256.file(value).hexdigest
elsif value.respond_to?(:read)
sha256 = OpenSSL::Digest::SHA256.new
loop do
chunk = value.read(1024 * 1024) # 1MB
break unless chunk
sha256.update(chunk)
end
value.rewind
sha256.hexdigest
else
OpenSSL::Digest::SHA256.hexdigest(value)
end
end
end # class Base
end # Module SES
Expand Down