escher 0.2.1 → 0.3.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: ba40c7a7b12bb5aec126c7151b4a8cf81eaa3f51
4
- data.tar.gz: 3fa09ffdec0d12b2bb7deaaf9aa30cecc23dfe63
3
+ metadata.gz: a08450345302a7c0e0b1d789ef7a23850151f234
4
+ data.tar.gz: ff3bfb71fba27c558bc3abd9e96331dc8bd1c354
5
5
  SHA512:
6
- metadata.gz: b5060b34740b6300e7f9abef1ee5ef32c4470336dbf956d74047a9e9048e5990b9755738f650221db734493d3a0ad27101905c669111edbda9e9b52f9dbce555
7
- data.tar.gz: 4fdcfda5426410a5ba9b465099630d626a72ce105bffdd968765ac284bdbfe79057a594f942822cbb2b9c351a72c94a702f86ad4134de81a965cf56f839ec07d
6
+ metadata.gz: 2b68ae2a96d4ccfa0ad1fbbb2966056e316872e140ee984cd8d38eef474faee7c7b4f81b0e33ea654fce0aaa7f3785589fc53b8200b7ad24eb629b58d8ff5138
7
+ data.tar.gz: 8ada366ab04fc7895aca5026dcb8a2fc08603cdd604e74979ecb1ad238436b64fc9a07d23fd1d53b23f4c7bdf7b37c3c672abe7205c647ebf10acbd496a84686
data/escher.gemspec CHANGED
@@ -10,7 +10,7 @@ Gem::Specification.new do |spec|
10
10
  spec.email = ["andras.barthazi@emarsys.com"]
11
11
  spec.summary = %q{Library for HTTP request signing (Ruby implementation)}
12
12
  spec.description = %q{Escher helps you creating secure HTTP requests (for APIs) by signing HTTP(s) requests.}
13
- spec.homepage = "https://github.com/emartech/escher-ruby"
13
+ spec.homepage = "http://escherauth.io/"
14
14
  spec.license = "MIT"
15
15
 
16
16
  spec.files = `git ls-files -z`.split("\x0")
@@ -18,9 +18,13 @@ Gem::Specification.new do |spec|
18
18
  spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
19
  spec.require_paths = ["lib"]
20
20
 
21
+ spec.required_ruby_version = '>= 1.9'
22
+
21
23
  spec.add_development_dependency "bundler", "~> 1.6"
22
24
  spec.add_development_dependency "rake", "~> 10"
23
25
  spec.add_development_dependency "rspec", "~> 2"
24
26
 
27
+ spec.add_development_dependency "rack"
28
+
25
29
  spec.add_runtime_dependency "addressable", "~> 2.3"
26
30
  end
@@ -0,0 +1,341 @@
1
+ module Escher
2
+ class Auth
3
+
4
+ def initialize(credential_scope, options = {})
5
+ @credential_scope = credential_scope
6
+ @algo_prefix = options[:algo_prefix] || 'ESR'
7
+ @vendor_key = options[:vendor_key] || 'Escher'
8
+ @hash_algo = options[:hash_algo] || 'SHA256'
9
+ @current_time = options[:current_time] || Time.now
10
+ @auth_header_name = options[:auth_header_name] || 'X-Escher-Auth'
11
+ @date_header_name = options[:date_header_name] || 'X-Escher-Date'
12
+ @clock_skew = options[:clock_skew] || 900
13
+ @algo = create_algo
14
+ @algo_id = @algo_prefix + '-HMAC-' + @hash_algo
15
+ end
16
+
17
+
18
+
19
+ def sign!(req, client, headers_to_sign = [])
20
+ headers_to_sign |= [@date_header_name.downcase, 'host']
21
+
22
+ request = wrap_request req
23
+ raise EscherError, 'Missing header: Host' unless request.has_header? 'host'
24
+
25
+ request.set_header(@date_header_name, format_date_for_header) unless request.has_header? @date_header_name
26
+
27
+ signature = generate_signature(client[:api_secret], request.body, request.headers, request.method, headers_to_sign, request.path, request.query_values)
28
+ request.set_header(@auth_header_name, "#{@algo_id} Credential=#{client[:api_key_id]}/#{short_date(@current_time)}/#{@credential_scope}, SignedHeaders=#{prepare_headers_to_sign headers_to_sign}, Signature=#{signature}")
29
+
30
+ request.request
31
+ end
32
+
33
+
34
+
35
+ def is_valid?(*args)
36
+ begin
37
+ authenticate(*args)
38
+ return true
39
+ rescue
40
+ return false
41
+ end
42
+ end
43
+
44
+
45
+
46
+ def authenticate(req, key_db)
47
+ request = wrap_request req
48
+ method = request.method
49
+ body = request.body
50
+ headers = request.headers
51
+ path = request.path
52
+ query_parts = request.query_values
53
+
54
+ signature_from_query = get_signing_param('Signature', query_parts)
55
+
56
+ (['Host'] + (signature_from_query ? [] : [@auth_header_name, @date_header_name])).each do |header|
57
+ raise EscherError, 'Missing header: ' + header unless request.header header
58
+ end
59
+
60
+ if method == 'GET' && signature_from_query
61
+ raw_date = get_signing_param('Date', query_parts)
62
+ algorithm, api_key_id, short_date, credential_scope, signed_headers, signature, expires = get_auth_parts_from_query(query_parts)
63
+
64
+ body = 'UNSIGNED-PAYLOAD'
65
+ query_parts.delete [query_key_for('Signature'), signature]
66
+ query_parts = query_parts.map { |k, v| [uri_decode(k), uri_decode(v)] }
67
+ else
68
+ raw_date = request.header @date_header_name
69
+ auth_header = request.header @auth_header_name
70
+ algorithm, api_key_id, short_date, credential_scope, signed_headers, signature, expires = get_auth_parts_from_header(auth_header)
71
+ end
72
+
73
+ date = Time.parse(raw_date)
74
+ api_secret = key_db[api_key_id]
75
+
76
+ raise EscherError, 'Invalid API key' unless api_secret
77
+ raise EscherError, 'Only SHA256 and SHA512 hash algorithms are allowed' unless %w(SHA256 SHA512).include?(algorithm)
78
+ raise EscherError, 'Invalid request date' unless short_date(date) == short_date
79
+ raise EscherError, 'The request date is not within the accepted time range' unless is_date_within_range?(date, expires)
80
+ raise EscherError, 'Invalid credentials' unless credential_scope == @credential_scope
81
+ raise EscherError, 'Host header is not signed' unless signed_headers.include? 'host'
82
+ raise EscherError, 'Only the host header should be signed' if signature_from_query && signed_headers != ['host']
83
+ raise EscherError, 'Date header is not signed' if !signature_from_query && !signed_headers.include?(@date_header_name.downcase)
84
+
85
+ escher = reconfig(algorithm, credential_scope, date)
86
+ expected_signature = escher.generate_signature(api_secret, body, headers, method, signed_headers, path, query_parts)
87
+ raise EscherError, 'The signatures do not match' unless signature == expected_signature
88
+ api_key_id
89
+ end
90
+
91
+
92
+
93
+ def reconfig(algorithm, credential_scope, date)
94
+ self.class.new(
95
+ credential_scope,
96
+ algo_prefix: @algo_prefix,
97
+ vendor_key: @vendor_key,
98
+ hash_algo: algorithm,
99
+ auth_header_name: @auth_header_name,
100
+ date_header_name: @date_header_name,
101
+ current_time: date
102
+ )
103
+ end
104
+
105
+
106
+
107
+ def generate_signed_url(url_to_sign, client, expires = 86400)
108
+ uri = Addressable::URI.parse(url_to_sign)
109
+ host = uri.host
110
+ path = uri.path
111
+ query_parts = (uri.query || '')
112
+ .split('&', -1)
113
+ .map { |pair| pair.split('=', -1) }
114
+ .map { |k, v| (k.include? ' ') ? [k.str(/\S+/), ''] : [k, v] }
115
+
116
+ headers = [['host', host]]
117
+ headers_to_sign = ['host']
118
+ body = 'UNSIGNED-PAYLOAD'
119
+ query_parts += [
120
+ ['Algorithm', @algo_id],
121
+ ['Credentials', "#{client[:api_key_id]}/#{short_date(@current_time)}/#{@credential_scope}"],
122
+ ['Date', long_date(@current_time)],
123
+ ['Expires', expires.to_s],
124
+ ['SignedHeaders', headers_to_sign.join(';')],
125
+ ].map { |k, v| query_pair(k, v) }
126
+
127
+ signature = generate_signature(client[:api_secret], body, headers, 'GET', headers_to_sign, path, query_parts)
128
+ query_parts_with_signature = (query_parts.map { |k, v| [uri_encode(k), uri_encode(v)] } << query_pair('Signature', signature))
129
+
130
+ uri.scheme + '://' + host + path + '?' + query_parts_with_signature.map { |k, v| k + '=' + v }.join('&')
131
+ end
132
+
133
+
134
+
135
+ def query_pair(k, v)
136
+ [query_key_for(k), v]
137
+ end
138
+
139
+
140
+
141
+ def query_key_for(key)
142
+ "X-#{@vendor_key}-#{key}"
143
+ end
144
+
145
+
146
+
147
+ def get_signing_param(key, query_parts)
148
+ the_param = (query_parts.detect { |param| param[0] === query_key_for(key) })
149
+ the_param ? uri_decode(the_param[1]) : nil
150
+ end
151
+
152
+
153
+
154
+ def get_auth_parts_from_header(auth_header)
155
+ m = /#{@algo_prefix}-HMAC-(?<algo>[A-Z0-9\,]+) Credential=(?<api_key_id>[A-Za-z0-9\-_]+)\/(?<short_date>[0-9]{8})\/(?<credentials>[A-Za-z0-9\-_\/]+), SignedHeaders=(?<signed_headers>[A-Za-z\-;]+), Signature=(?<signature>[0-9a-f]+)$/
156
+ .match auth_header
157
+ raise EscherError, 'Malformed authorization header' unless m && m['credentials']
158
+ return m['algo'], m['api_key_id'], m['short_date'], m['credentials'], m['signed_headers'].split(';'), m['signature'], 0
159
+ end
160
+
161
+
162
+
163
+ def get_auth_parts_from_query(query_parts)
164
+ expires = get_signing_param('Expires', query_parts).to_i
165
+ api_key_id, short_date, credential_scope = get_signing_param('Credentials', query_parts).split('/', 3)
166
+ signed_headers = get_signing_param('SignedHeaders', query_parts).split ';'
167
+ algorithm = parse_algo(get_signing_param('Algorithm', query_parts))
168
+ signature = get_signing_param('Signature', query_parts)
169
+ return algorithm, api_key_id, short_date, credential_scope, signed_headers, signature, expires
170
+ end
171
+
172
+
173
+
174
+ def generate_signature(api_secret, body, headers, method, signed_headers, path, query_parts)
175
+ canonicalized_request = canonicalize(method, path, query_parts, body, headers, signed_headers.uniq)
176
+ string_to_sign = get_string_to_sign(canonicalized_request)
177
+
178
+ signing_key = Digest::HMAC.digest(short_date(@current_time), @algo_prefix + api_secret, @algo)
179
+ @credential_scope.split('/').each { |data|
180
+ signing_key = Digest::HMAC.digest(data, signing_key, @algo)
181
+ }
182
+
183
+ Digest::HMAC.hexdigest(string_to_sign, signing_key, @algo)
184
+ end
185
+
186
+
187
+
188
+ def format_date_for_header
189
+ @date_header_name.downcase == 'date' ? @current_time.utc.rfc2822.sub('-0000', 'GMT') : long_date(@current_time)
190
+ end
191
+
192
+
193
+
194
+ def canonicalize(method, path, query_parts, body, headers, headers_to_sign)
195
+ [
196
+ method,
197
+ canonicalize_path(path),
198
+ canonicalize_query(query_parts),
199
+ canonicalize_headers(headers, headers_to_sign).join("\n"),
200
+ '',
201
+ prepare_headers_to_sign(headers_to_sign),
202
+ @algo.new.hexdigest(body)
203
+ ].join "\n"
204
+ end
205
+
206
+
207
+
208
+ def prepare_headers_to_sign(headers_to_sign)
209
+ headers_to_sign.sort.uniq.join(';')
210
+ end
211
+
212
+
213
+
214
+ def parse_uri(request_uri)
215
+ path, query = request_uri.split '?', 2
216
+ return path, (query || '')
217
+ .split('&', -1)
218
+ .map { |pair| pair.split('=', -1) }
219
+ .map { |k, v| (k.include? ' ') ? [k.str(/\S+/), ''] : [k, v] }
220
+ end
221
+
222
+
223
+
224
+ def get_string_to_sign(canonicalized_request)
225
+ [
226
+ @algo_id,
227
+ long_date(@current_time),
228
+ short_date(@current_time) + '/' + @credential_scope,
229
+ @algo.new.hexdigest(canonicalized_request)
230
+ ].join("\n")
231
+ end
232
+
233
+
234
+
235
+ def create_algo
236
+ case @hash_algo
237
+ when 'SHA256'
238
+ @algo = Digest::SHA2.new(256)
239
+ when 'SHA512'
240
+ @algo = Digest::SHA2.new(512)
241
+ else
242
+ raise EscherError, 'Unidentified hash algorithm'
243
+ end
244
+ end
245
+
246
+
247
+
248
+ def long_date(date)
249
+ date.utc.strftime('%Y%m%dT%H%M%SZ')
250
+ end
251
+
252
+
253
+
254
+ def short_date(date)
255
+ date.utc.strftime('%Y%m%d')
256
+ end
257
+
258
+
259
+
260
+ def is_date_within_range?(request_date, expires)
261
+ (request_date - @clock_skew .. request_date + expires + @clock_skew).cover? @current_time
262
+ end
263
+
264
+
265
+
266
+ def parse_algo(algorithm)
267
+ m = /^#{@algo_prefix}-HMAC-(?<algo>[A-Z0-9\,]+)$/.match(algorithm)
268
+ m && m['algo']
269
+ end
270
+
271
+
272
+
273
+ def canonicalize_path(path)
274
+ while path.gsub!(%r{([^/]+)/\.\./?}) { |match| $1 == '..' ? match : '' } do
275
+ end
276
+ path.gsub(%r{/\./}, '/').sub(%r{/\.\z}, '/').gsub(/\/+/, '/')
277
+ end
278
+
279
+
280
+
281
+ def canonicalize_headers(raw_headers, headers_to_sign)
282
+ headers = {}
283
+ raw_headers.each do |raw_header|
284
+ if raw_header[0].downcase != @auth_header_name.downcase
285
+ if headers[raw_header[0].downcase]
286
+ headers[raw_header[0].downcase] << raw_header[1]
287
+ else
288
+ headers[raw_header[0].downcase] = [raw_header[1]]
289
+ end
290
+ end
291
+ end
292
+ headers
293
+ .sort
294
+ .select { |h| headers_to_sign.include?(h[0]) }
295
+ .map { |k, v| k + ':' + v.map { |piece| normalize_white_spaces piece }.join(',') }
296
+ end
297
+
298
+
299
+
300
+ def normalize_white_spaces(value)
301
+ value.strip.split('"', -1).map.with_index { |piece, index|
302
+ is_inside_of_quotes = (index % 2 === 1)
303
+ is_inside_of_quotes ? piece : piece.gsub(/\s+/, ' ')
304
+ }.join '"'
305
+ end
306
+
307
+
308
+
309
+ def canonicalize_query(query_parts)
310
+ query_parts
311
+ .map { |k, v| uri_encode(k.gsub('+', ' ')) + '=' + uri_encode(v || '') }
312
+ .sort.join '&'
313
+ end
314
+
315
+
316
+
317
+ def uri_encode(component)
318
+ Addressable::URI.encode_component(component, Addressable::URI::CharacterClasses::UNRESERVED)
319
+ end
320
+
321
+
322
+
323
+ def uri_decode(component)
324
+ Addressable::URI.unencode_component(component)
325
+ end
326
+
327
+
328
+
329
+ private
330
+
331
+ def wrap_request(request)
332
+ Escher::Request::Factory.from_request request
333
+ end
334
+
335
+ end
336
+
337
+
338
+ class EscherError < RuntimeError
339
+ end
340
+
341
+ end
@@ -0,0 +1,29 @@
1
+ module Escher
2
+ module Request
3
+ class Base
4
+
5
+ attr_reader :request
6
+
7
+
8
+
9
+ def initialize(request)
10
+ @request = request
11
+ end
12
+
13
+
14
+
15
+ def has_header?(name)
16
+ not header(name).nil?
17
+ end
18
+
19
+
20
+
21
+ def header(name)
22
+ header = headers.find { |(header_name, _)| header_name.downcase == name.downcase }
23
+ return nil if header.nil?
24
+ header[1]
25
+ end
26
+
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,24 @@
1
+ require_relative 'base'
2
+
3
+ require_relative 'hash_request'
4
+ require_relative 'legacy_request'
5
+ require_relative 'rack_request'
6
+
7
+ module Escher
8
+ module Request
9
+ class Factory
10
+
11
+ def self.from_request(request)
12
+ case request
13
+ when Hash
14
+ HashRequest.new request
15
+ when lambda { |request| request.class.ancestors.map(&:to_s).include? "Rack::Request" }
16
+ RackRequest.new request
17
+ else
18
+ Escher::Request::LegacyRequest.new request
19
+ end
20
+ end
21
+
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,67 @@
1
+ module Escher
2
+ module Request
3
+ class HashRequest < Base
4
+
5
+ # Based on the example in RFC 3986, but scheme, user, password,
6
+ # host, port and fragment support removed, only path and query left
7
+ URI_REGEXP = /^([^?#]*)(\?(.*))?$/
8
+
9
+
10
+
11
+ def initialize(request)
12
+ super request
13
+ @uri = parse_uri request[:uri]
14
+ end
15
+
16
+
17
+
18
+ def headers
19
+ request[:headers].map { |(header_name, value)| [header_name.gsub('_', '-'), value] }
20
+ end
21
+
22
+
23
+
24
+ def set_header(name, value)
25
+ request[:headers] ||= []
26
+ request[:headers] << [name, value] unless has_header? name
27
+ end
28
+
29
+
30
+
31
+ def method
32
+ request[:method]
33
+ end
34
+
35
+
36
+
37
+ def body
38
+ request[:body] or ''
39
+ end
40
+
41
+
42
+
43
+ def path
44
+ @uri.path
45
+ end
46
+
47
+
48
+
49
+ def query_values
50
+ @uri.query_values(Array) or []
51
+ end
52
+
53
+
54
+
55
+ private
56
+
57
+ def parse_uri(uri)
58
+ uri.match URI_REGEXP do |match_data|
59
+ Addressable::URI.new({:path => match_data[1],
60
+ :query => match_data[3]})
61
+ end
62
+ end
63
+
64
+ end
65
+
66
+ end
67
+ end
@@ -0,0 +1,157 @@
1
+ module Escher
2
+ module Request
3
+ class LegacyRequest
4
+
5
+ # based on the example in RFC 3986, but scheme, user, password,
6
+ # host, port and fraement support removed, only path and query left
7
+ URIREGEX = /^([^?#]*)(\?(.*))?$/
8
+
9
+
10
+
11
+ def initialize(request)
12
+ @request = request
13
+ prepare_request_uri
14
+ prepare_request_headers
15
+ end
16
+
17
+
18
+
19
+ def prepare_request_uri
20
+ case @request.class.to_s
21
+ when 'Hash'
22
+ uri = @request[:uri]
23
+ else
24
+ uri = @request.uri
25
+ end
26
+ fragments = uri.scan(URIREGEX)[0]
27
+ @request_uri = Addressable::URI.new({
28
+ :path => fragments[0],
29
+ :query => fragments[2],
30
+ })
31
+ raise "Invalid request URI: #{@request_uri}" unless @request_uri
32
+ end
33
+
34
+
35
+
36
+ def prepare_request_headers
37
+ @request_headers = []
38
+ case @request.class.to_s
39
+ when 'Hash'
40
+ @request_headers = @request[:headers]
41
+ when 'Sinatra::Request' # TODO: not working yet
42
+ @request.env.each { |key, value|
43
+ if key.downcase[0, 5] == "http_"
44
+ @request_headers += [[key[5..-1].gsub("_", "-"), value]]
45
+ end
46
+ }
47
+ when 'WEBrick::HTTPRequest'
48
+ @request.header.each { |key, values|
49
+ values.each { |value|
50
+ @request_headers += [[key, value]]
51
+ }
52
+ }
53
+ end
54
+ end
55
+
56
+
57
+
58
+ def request
59
+ @request
60
+ end
61
+
62
+
63
+
64
+ def headers
65
+ @request_headers
66
+ end
67
+
68
+
69
+
70
+ def set_header(key, value)
71
+ found = false
72
+ @request_headers.each { |header|
73
+ if not found and header[0].downcase == key.downcase
74
+ header[1] = value
75
+ found = true
76
+ end
77
+ }
78
+ unless found
79
+ @request_headers += [[key, value]]
80
+ end
81
+ case @request.class.to_s
82
+ when 'Hash'
83
+ @request[:headers] = @request_headers
84
+ else
85
+ @request[key] = value
86
+ end
87
+ end
88
+
89
+
90
+
91
+ def has_header?(key)
92
+ @request_headers.each { |header|
93
+ if header[0].downcase == key.downcase
94
+ return true
95
+ end
96
+ }
97
+ return false
98
+ end
99
+
100
+
101
+
102
+ def method
103
+ case @request.class.to_s
104
+ when 'Hash'
105
+ @request[:method]
106
+ else
107
+ @request.request_method
108
+ end
109
+ end
110
+
111
+
112
+
113
+ # TODO: create a test for empty body (= nil)
114
+ def body
115
+ case @request.class.to_s
116
+ when 'Hash'
117
+ @request[:body] || ''
118
+ else
119
+ @request.body || ''
120
+ end
121
+ end
122
+
123
+
124
+
125
+ def host
126
+ @request_headers.each { |header|
127
+ if header[0].downcase == key.downcase
128
+ return header[1]
129
+ end
130
+ }
131
+ case @request.class.to_s
132
+ when 'Hash'
133
+ @request[:host]
134
+ else
135
+ begin
136
+ @request.host
137
+ rescue
138
+ ""
139
+ end
140
+ end
141
+ end
142
+
143
+
144
+
145
+ def path
146
+ @request_uri.path
147
+ end
148
+
149
+
150
+
151
+ def query_values
152
+ @request_uri.query_values(Array) || []
153
+ end
154
+
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,42 @@
1
+ module Escher
2
+ module Request
3
+ class RackRequest < Base
4
+
5
+ def headers
6
+ request.env.
7
+ select { |header_name, _| header_name.start_with? "HTTP_" }.
8
+ map { |header_name, value| [header_name[5..-1].tr('_', '-'), value] }
9
+ end
10
+
11
+
12
+
13
+ def method
14
+ request.request_method
15
+ end
16
+
17
+
18
+
19
+ def body
20
+ request.body or ''
21
+ end
22
+
23
+
24
+
25
+ def path
26
+ request.env['REQUEST_PATH']
27
+ end
28
+
29
+
30
+
31
+ def query_values
32
+ Addressable::URI.new(:query => request.env['QUERY_STRING']).query_values(Array) or []
33
+ end
34
+
35
+
36
+
37
+ def set_header(header_name, value)
38
+ end
39
+
40
+ end
41
+ end
42
+ end
@@ -1,3 +1,3 @@
1
- class Escher
2
- VERSION = '0.2.1'
1
+ module Escher
2
+ VERSION = '0.3.1'
3
3
  end