Skip to content

Commit 821faad

Browse files
committed
[#78] more efficient sigv4 signing method
1 parent f4f2d79 commit 821faad

1 file changed

Lines changed: 110 additions & 74 deletions

File tree

lib/aws/ses/base.rb

Lines changed: 110 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,52 @@
11
module AWS #:nodoc:
22
# AWS::SES is a Ruby library for Amazon's Simple Email Service's REST API (http://aws.amazon.com/ses).
3-
#
3+
#
44
# == Getting started
5-
#
5+
#
66
# To get started you need to require 'aws/ses':
7-
#
7+
#
88
# % irb -rubygems
99
# irb(main):001:0> require 'aws/ses'
1010
# # => true
11-
#
11+
#
1212
# Before you can do anything, you must establish a connection using Base.new. A basic connection would look something like this:
13-
#
13+
#
1414
# ses = AWS::SES::Base.new(
15-
# :access_key_id => 'abc',
15+
# :access_key_id => 'abc',
1616
# :secret_access_key => '123'
1717
# )
18-
#
18+
#
1919
# The minimum connection options that you must specify are your access key id and your secret access key.
2020
#
2121
# === Connecting to a server from another region
22-
#
22+
#
2323
# The default server API endpoint is "email.us-east-1.amazonaws.com", corresponding to the US East 1 region.
2424
# To connect to a different one, just pass it as a parameter to the AWS::SES::Base initializer:
2525
#
2626
# ses = AWS::SES::Base.new(
27-
# :access_key_id => 'abc',
27+
# :access_key_id => 'abc',
2828
# :secret_access_key => '123',
2929
# :server => 'email.eu-west-1.amazonaws.com',
3030
# :message_id_domain => 'eu-west-1.amazonses.com'
3131
# )
3232
#
3333

3434
module SES
35-
35+
36+
SERVICE = 'ses'
37+
3638
API_VERSION = '2010-12-01'
3739

3840
DEFAULT_REGION = 'us-east-1'
3941

40-
SERVICE = 'ec2'
42+
USER_AGENT = 'github-aws-ses-ruby-gem'
4143

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

4446
DEFAULT_MESSAGE_ID_DOMAIN = 'email.amazonses.com'
45-
46-
USER_AGENT = 'github-aws-ses-ruby-gem'
47-
47+
48+
UNSIGNED_HEADERS = ['content-length', 'user-agent', 'authorization']
49+
4850
# Encodes the given string with the secret_access_key by taking the
4951
# hmac-sha1 sum, and then base64 encoding it. Optionally, it will also
5052
# url encode the result of that to protect the string if it's going to
@@ -66,9 +68,9 @@ def SES.encode(secret_access_key, str, urlencode=true)
6668
return b64_hmac
6769
end
6870
end
69-
71+
7072
# Generates the HTTP Header String that Amazon looks for
71-
#
73+
#
7274
# @param [String] key the AWS Access Key ID
7375
# @param [String] alg the algorithm used for the signature
7476
# @param [String] sig the signature itself
@@ -81,11 +83,11 @@ def SES.authorization_header_v4(credential, signed_headers, signature)
8183
end
8284

8385
# AWS::SES::Base is the abstract super class of all classes who make requests against SES
84-
class Base
86+
class Base
8587
include SendEmail
8688
include Info
87-
88-
attr_reader :use_ssl, :server, :proxy_server, :port, :message_id_domain, :signature_version, :region
89+
90+
attr_reader :use_ssl, :server, :proxy_server, :port, :message_id_domain, :signature_version, :region, :datetime, :date, :headers, :query, :action
8991
attr_accessor :settings
9092

9193
# @option options [String] :access_key_id ("") The user's AWS Access Key ID
@@ -125,6 +127,7 @@ def initialize( options = {} )
125127
raise ArgumentError, "No :use_ssl value provided" if options[:use_ssl].nil?
126128
raise ArgumentError, "Invalid :use_ssl value provided, only 'true' or 'false' allowed" unless options[:use_ssl] == true || options[:use_ssl] == false
127129
raise ArgumentError, "No :server provided" if options[:server].nil? || options[:server].empty?
130+
raise ArgumentError, "signature_version must be `2` or `4`" unless signature_version == 2 || signature_version == 4
128131

129132
if options[:port]
130133
# user-specified port
@@ -150,19 +153,22 @@ def initialize( options = {} )
150153

151154
@http.use_ssl = @use_ssl
152155
end
153-
156+
154157
def connection
155158
@http
156159
end
157-
158-
# Make the connection to AWS passing in our request.
160+
161+
# Make the connection to AWS passing in our request.
159162
# allow us to have a one line call in each method which will do all of the work
160163
# in making the actual request to AWS.
161164
def request(action, params = {})
162165
# Use a copy so that we don't modify the caller's Hash, remove any keys that have nil or empty values
166+
@action = action
163167
params = params.reject { |key, value| value.nil? or value.empty?}
164-
165-
timestamp = Time.now.getutc
168+
169+
timestamp = Time.now.utc
170+
@datetime = timestamp.strftime("%Y%m%dT%H%M%SZ")
171+
@date = @datetime[0, 8]
166172

167173
params.merge!( {"Action" => action,
168174
"SignatureVersion" => signature_version.to_s,
@@ -171,100 +177,130 @@ def request(action, params = {})
171177
"Version" => API_VERSION,
172178
"Timestamp" => timestamp.iso8601 } )
173179

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

178-
req = {}
184+
@headers = {}
185+
@headers['host'] = @server
186+
@headers['x-amz-date'] = @datetime
187+
@headers['user-agent'] = @user_agent
188+
@headers[signature_version == 4 ? 'authorization' : 'x-amzn-authorization'] = gen_authorization(timestamp.httpdate)
179189

180-
req['X-Amzn-Authorization'] = get_aws_auth_param(timestamp.httpdate, @secret_access_key, action, signature_version)
181-
req['Date'] = timestamp.httpdate
182-
req['User-Agent'] = @user_agent
190+
response = connection.post(@path, @query, @headers)
183191

184-
response = connection.post(@path, query, req)
185-
186192
response_class = AWS::SES.const_get( "#{action}Response" )
187193
result = response_class.new(action, response)
188-
194+
189195
if result.error?
190196
raise ResponseError.new(result)
191197
end
192-
198+
193199
result
194200
end
195201

196202
# Set the Authorization header using AWS signed header authentication
197-
def get_aws_auth_param(timestamp, secret_access_key, action = '', signature_version = 2)
198-
raise(ArgumentError, "signature_version must be `2` or `4`") unless signature_version == 2 || signature_version == 4
199-
encoded_canonical = SES.encode(secret_access_key, timestamp, false)
200-
203+
def gen_authorization(timestamp)
201204
if signature_version == 4
202-
SES.authorization_header_v4(sig_v4_auth_credential, sig_v4_auth_signed_headers, sig_v4_auth_signature(action))
205+
sigv4 = signature(date, region, SERVICE, string_to_sign)
206+
SES.authorization_header_v4(credentials, signed_headers, sigv4)
203207
else
208+
encoded_canonical = SES.encode(@secret_access_key, timestamp, false)
204209
SES.authorization_header(@access_key_id, 'HmacSHA256', encoded_canonical)
205210
end
206211
end
207212

208213
private
209214

210-
def sig_v4_auth_credential
211-
@access_key_id + '/' + credential_scope
212-
end
213-
214-
def sig_v4_auth_signed_headers
215-
'host;x-amz-date'
216-
end
217-
218-
def credential_scope
219-
datestamp + '/' + region + '/' + SERVICE + '/' + 'aws4_request'
215+
def canonical_uri
216+
'/'
220217
end
221218

222-
def string_to_sign(for_action)
223-
"AWS4-HMAC-SHA256\n" + amzdate + "\n" + credential_scope + "\n" + Digest::SHA256.hexdigest(canonical_request(for_action).encode('utf-8').b)
219+
def canonical_querystring
220+
signature_version == 2 ? "Action=#{action}&Version=2013-10-15" : ''
224221
end
225222

226-
227-
def amzdate
228-
Time.now.utc.strftime('%Y%m%dT%H%M%SZ')
223+
def canonical_request
224+
[
225+
'POST',
226+
canonical_uri,
227+
canonical_querystring,
228+
canonical_headers + "\n",
229+
signed_headers,
230+
sha256_hexdigest(@query),
231+
].join("\n")
229232
end
230233

231-
def datestamp
232-
Time.now.utc.strftime('%Y%m%d')
234+
def credential_scope
235+
date + '/' + region + '/' + SERVICE + '/' + 'aws4_request'
233236
end
234237

235-
def canonical_request(for_action)
236-
"GET" + "\n" + "/" + "\n" + canonical_querystring(for_action) + "\n" + canonical_headers + "\n" + sig_v4_auth_signed_headers + "\n" + payload_hash
238+
def string_to_sign
239+
[
240+
'AWS4-HMAC-SHA256',
241+
datetime,
242+
credential_scope,
243+
sha256_hexdigest(canonical_request),
244+
].join("\n")
237245
end
238246

239-
def canonical_querystring(action)
240-
"Action=#{action}&Version=2013-10-15"
247+
def signed_headers
248+
@headers.inject([]) do |signed_headers, (header, _)|
249+
if UNSIGNED_HEADERS.include?(header)
250+
signed_headers
251+
else
252+
signed_headers << header
253+
end
254+
end.sort.join(';')
241255
end
242256

243257
def canonical_headers
244-
'host:' + server + "\n" + 'x-amz-date:' + amzdate + "\n"
258+
_headers = @headers.inject([]) do |hdrs, (k,v)|
259+
if UNSIGNED_HEADERS.include?(k)
260+
hdrs
261+
else
262+
hdrs << [k,v]
263+
end
264+
end
265+
_headers = _headers.sort_by(&:first)
266+
_headers.map{|k,v| "#{k}:#{canonical_header_value(v.to_s)}" }.join("\n")
245267
end
246268

247-
def payload_hash
248-
Digest::SHA256.hexdigest(''.encode('utf-8'))
269+
def canonical_header_value(value)
270+
value.match(/^".*"$/) ? value : value.gsub(/\s+/, ' ').strip
249271
end
250272

251-
def sig_v4_auth_signature(for_action)
252-
signing_key = getSignatureKey(@secret_access_key, datestamp, region, SERVICE)
253-
254-
OpenSSL::HMAC.hexdigest("SHA256", signing_key, string_to_sign(for_action).encode('utf-8'))
273+
def credentials
274+
@access_key_id + '/' + credential_scope
255275
end
256276

257-
def getSignatureKey(key, dateStamp, regionName, serviceName)
258-
kDate = sign(('AWS4' + key).encode('utf-8'), dateStamp)
259-
kRegion = sign(kDate, regionName)
260-
kService = sign(kRegion, serviceName)
261-
kSigning = sign(kService, 'aws4_request')
277+
def signature(date, region, service, string_to_sign)
278+
k_date = hmac("AWS4" + @secret_access_key, date)
279+
k_region = hmac(k_date, region)
280+
k_service = hmac(k_region, service)
281+
k_credentials = hmac(k_service, 'aws4_request')
282+
OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), k_credentials, string_to_sign)
283+
end
262284

263-
kSigning
285+
def hmac(key, value)
286+
OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha256'), key, value)
264287
end
265288

266-
def sign(key, msg)
267-
OpenSSL::HMAC.digest("SHA256", key, msg.encode('utf-8'))
289+
def sha256_hexdigest(value)
290+
if (File === value || Tempfile === value) && !value.path.nil? && File.exist?(value.path)
291+
OpenSSL::Digest::SHA256.file(value).hexdigest
292+
elsif value.respond_to?(:read)
293+
sha256 = OpenSSL::Digest::SHA256.new
294+
loop do
295+
chunk = value.read(1024 * 1024) # 1MB
296+
break unless chunk
297+
sha256.update(chunk)
298+
end
299+
value.rewind
300+
sha256.hexdigest
301+
else
302+
OpenSSL::Digest::SHA256.hexdigest(value)
303+
end
268304
end
269305
end # class Base
270306
end # Module SES

0 commit comments

Comments
 (0)