Skip to content

Commit

Permalink
[drewblas#78] Efficient AWS SES sigv4 generation based on given paylo…
Browse files Browse the repository at this point in the history
…ad; changed delivery service on canonical (which is used by aws-core); request method has been redesigned to allow them to be signed during encryption;
  • Loading branch information
svmax committed Nov 3, 2020
1 parent f4f2d79 commit 65e1ff1
Showing 1 changed file with 91 additions and 56 deletions.
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

0 comments on commit 65e1ff1

Please sign in to comment.