escher 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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