Class: Escher
- Inherits:
-
Object
- Object
- Escher
- Defined in:
- lib/escher.rb,
lib/escher/version.rb
Constant Summary collapse
- VERSION =
"0.0.5"
Instance Method Summary collapse
- #add_defaults_to(headers, host) ⇒ Object
- #add_if_missing(headers, header_to_find, value) ⇒ Object
- #calculate_signing_key(api_secret) ⇒ Object
- #canonicalize(method, path, query_parts, body, headers, headers_to_sign) ⇒ Object
- #canonicalize_headers(raw_headers, headers_to_sign) ⇒ Object
- #canonicalize_path(path) ⇒ Object
- #canonicalize_query(query_parts) ⇒ Object
- #collect_headers(raw_headers) ⇒ Object
- #create_algo ⇒ Object
- #format_date_for_header ⇒ Object
- #generate_auth_header(client, method, host, request_uri, body, headers, headers_to_sign) ⇒ Object
- #generate_signature(api_secret, body, headers, method, signed_headers, path, query_parts) ⇒ Object
- #generate_signed_url(url_to_sign, client, expires = 86400) ⇒ Object
- #get_algorithm_id ⇒ Object
- #get_auth_parts_from_header(auth_header) ⇒ Object
- #get_auth_parts_from_query(query_parts) ⇒ Object
- #get_header(header_name, headers) ⇒ Object
- #get_signing_param(key, query_parts) ⇒ Object
- #get_signing_params(client, expires, headers_to_sign) ⇒ Object
- #get_string_to_sign(canonicalized_req) ⇒ Object
-
#initialize(credential_scope, options) ⇒ Escher
constructor
A new instance of Escher.
- #is_date_within_range?(request_date, expires) ⇒ Boolean
- #is_valid?(*args) ⇒ Boolean
- #long_date(date) ⇒ Object
- #normalize_white_spaces(value) ⇒ Object
- #parse_algo(algorithm) ⇒ Object
- #parse_query(query) ⇒ Object
- #parse_uri(request_uri) ⇒ Object
- #prepare_headers_to_sign(headers_to_sign) ⇒ Object
- #query_key_for(key) ⇒ Object
- #query_key_truncate(key) ⇒ Object
- #query_pair(k, v) ⇒ Object
- #reconfig(algorithm, credential_scope, date) ⇒ Object
- #short_date(date) ⇒ Object
- #sign!(request, client) ⇒ Object
- #uri_decode(component) ⇒ Object
- #uri_encode(component) ⇒ Object
- #validate(request, key_db) ⇒ Object
- #validate_headers(headers, using_query_string_for_validation) ⇒ Object
- #validate_request(key_db, method, request_uri, body, headers) ⇒ Object
Constructor Details
#initialize(credential_scope, options) ⇒ Escher
Returns a new instance of Escher.
13 14 15 16 17 18 19 20 21 22 |
# File 'lib/escher.rb', line 13 def initialize(credential_scope, ) @credential_scope = credential_scope @algo_prefix = [:algo_prefix] || 'ESR' @vendor_key = [:vendor_key] || 'Escher' @hash_algo = [:hash_algo] || 'SHA256' @current_time = [:current_time] || Time.now @auth_header_name = [:auth_header_name] || 'X-Escher-Auth' @date_header_name = [:date_header_name] || 'X-Escher-Date' @clock_skew = [:clock_skew] || 900 end |
Instance Method Details
#add_defaults_to(headers, host) ⇒ Object
186 187 188 189 190 |
# File 'lib/escher.rb', line 186 def add_defaults_to(headers, host) [['host', host], [@date_header_name, format_date_for_header]] .each { |k, v| headers = add_if_missing headers, k, v } headers end |
#add_if_missing(headers, header_to_find, value) ⇒ Object
196 197 198 199 |
# File 'lib/escher.rb', line 196 def add_if_missing(headers, header_to_find, value) headers += [header_to_find, value] unless headers.find { |header| header[0].downcase == header_to_find.downcase } headers end |
#calculate_signing_key(api_secret) ⇒ Object
269 270 271 272 273 274 275 276 277 |
# File 'lib/escher.rb', line 269 def calculate_signing_key(api_secret) algo = create_algo signing_key = @algo_prefix + api_secret key_parts = [short_date(@current_time)] + @credential_scope.split('/') key_parts.each { |data| signing_key = Digest::HMAC.digest(data, signing_key, algo) } signing_key end |
#canonicalize(method, path, query_parts, body, headers, headers_to_sign) ⇒ Object
201 202 203 204 205 206 207 208 209 210 |
# File 'lib/escher.rb', line 201 def canonicalize(method, path, query_parts, body, headers, headers_to_sign) [ method, canonicalize_path(path), canonicalize_query(query_parts), canonicalize_headers(headers, headers_to_sign).join("\n"), '', prepare_headers_to_sign(headers_to_sign), create_algo.new.hexdigest(body || '') # TODO: we should set the default value at the same level at every implementation ].join "\n" end |
#canonicalize_headers(raw_headers, headers_to_sign) ⇒ Object
284 285 286 287 288 289 |
# File 'lib/escher.rb', line 284 def canonicalize_headers(raw_headers, headers_to_sign) collect_headers(raw_headers) .sort .select { |k, v| headers_to_sign.include?(k) } .map { |k, v| k + ':' + v.map { |piece| normalize_white_spaces piece} .join(',') } end |
#canonicalize_path(path) ⇒ Object
279 280 281 282 |
# File 'lib/escher.rb', line 279 def canonicalize_path(path) while path.gsub!(%r{([^/]+)/\.\./?}) { |match| $1 == '..' ? match : '' } do end path.gsub(%r{/\./}, '/').sub(%r{/\.\z}, '/').gsub(/\/+/, '/') end |
#canonicalize_query(query_parts) ⇒ Object
312 313 314 315 316 |
# File 'lib/escher.rb', line 312 def canonicalize_query(query_parts) query_parts .map { |k, v| uri_encode(k.gsub('+', ' ')) + '=' + uri_encode(v || '') } .sort.join '&' end |
#collect_headers(raw_headers) ⇒ Object
298 299 300 301 302 303 304 305 306 307 308 309 310 |
# File 'lib/escher.rb', line 298 def collect_headers(raw_headers) headers = {} raw_headers.each do |raw_header| if raw_header[0].downcase != @auth_header_name.downcase if headers[raw_header[0].downcase] headers[raw_header[0].downcase] << raw_header[1] else headers[raw_header[0].downcase] = [raw_header[1]] end end end headers end |
#create_algo ⇒ Object
237 238 239 240 241 242 243 244 245 246 |
# File 'lib/escher.rb', line 237 def create_algo case @hash_algo when 'SHA256' return Digest::SHA2.new 256 when 'SHA512' return Digest::SHA2.new 512 else raise EscherError, 'Unidentified hash algorithm' end end |
#format_date_for_header ⇒ Object
192 193 194 |
# File 'lib/escher.rb', line 192 def format_date_for_header @date_header_name.downcase == 'date' ? @current_time.utc.rfc2822.sub('-0000', 'GMT') : long_date(@current_time) end |
#generate_auth_header(client, method, host, request_uri, body, headers, headers_to_sign) ⇒ Object
105 106 107 108 109 110 111 |
# File 'lib/escher.rb', line 105 def generate_auth_header(client, method, host, request_uri, body, headers, headers_to_sign) path, query_parts = parse_uri(request_uri) headers = add_defaults_to(headers, host) headers_to_sign |= [@date_header_name.downcase, 'host'] signature = generate_signature(client[:api_secret], body, headers, method, headers_to_sign, path, query_parts) "#{get_algorithm_id} Credential=#{client[:api_key_id]}/#{short_date(@current_time)}/#{@credential_scope}, SignedHeaders=#{prepare_headers_to_sign headers_to_sign}, Signature=#{signature}" end |
#generate_signature(api_secret, body, headers, method, signed_headers, path, query_parts) ⇒ Object
179 180 181 182 183 184 |
# File 'lib/escher.rb', line 179 def generate_signature(api_secret, body, headers, method, signed_headers, path, query_parts) canonicalized_request = canonicalize(method, path, query_parts, body, headers, signed_headers.uniq) string_to_sign = get_string_to_sign(canonicalized_request) signing_key = calculate_signing_key(api_secret) Digest::HMAC.hexdigest(string_to_sign, signing_key, create_algo) end |
#generate_signed_url(url_to_sign, client, expires = 86400) ⇒ Object
113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 |
# File 'lib/escher.rb', line 113 def generate_signed_url(url_to_sign, client, expires = 86400) uri = Addressable::URI.parse(url_to_sign) protocol = uri.scheme host = uri.host path = uri.path query_parts = parse_query(uri.query) headers = [['host', host]] headers_to_sign = ['host'] body = 'UNSIGNED-PAYLOAD' query_parts += get_signing_params(client, expires, headers_to_sign) signature = generate_signature(client[:api_secret], body, headers, 'GET', headers_to_sign, path, query_parts) query_parts_with_signature = (query_parts.map { |k, v| [uri_encode(k), uri_encode(v)] } << query_pair('Signature', signature)) protocol + '://' + host + path + '?' + query_parts_with_signature.map { |k, v| k + '=' + v }.join('&') end |
#get_algorithm_id ⇒ Object
260 261 262 |
# File 'lib/escher.rb', line 260 def get_algorithm_id @algo_prefix + '-HMAC-' + @hash_algo end |
#get_auth_parts_from_header(auth_header) ⇒ Object
163 164 165 166 167 168 |
# File 'lib/escher.rb', line 163 def get_auth_parts_from_header(auth_header) 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]+)$/ .match auth_header raise EscherError, 'Malformed authorization header' unless m && m['credentials'] return m['algo'], m['api_key_id'], m['short_date'], m['credentials'], m['signed_headers'].split(';'), m['signature'], 0 end |
#get_auth_parts_from_query(query_parts) ⇒ Object
170 171 172 173 174 175 176 177 |
# File 'lib/escher.rb', line 170 def get_auth_parts_from_query(query_parts) expires = get_signing_param('Expires', query_parts).to_i api_key_id, short_date, credential_scope = get_signing_param('Credentials', query_parts).split('/', 3) signed_headers = get_signing_param('SignedHeaders', query_parts).split ';' algorithm = parse_algo(get_signing_param('Algorithm', query_parts)) signature = get_signing_param('Signature', query_parts) return algorithm, api_key_id, short_date, credential_scope, signed_headers, signature, expires end |
#get_header(header_name, headers) ⇒ Object
153 154 155 156 |
# File 'lib/escher.rb', line 153 def get_header(header_name, headers) the_header = (headers.detect { |header| header[0].downcase == header_name.downcase }) the_header ? the_header[1] : nil end |
#get_signing_param(key, query_parts) ⇒ Object
158 159 160 161 |
# File 'lib/escher.rb', line 158 def get_signing_param(key, query_parts) the_param = (query_parts.detect { |param| param[0] === query_key_for(key) }) the_param ? uri_decode(the_param[1]) : nil end |
#get_signing_params(client, expires, headers_to_sign) ⇒ Object
131 132 133 134 135 136 137 138 139 |
# File 'lib/escher.rb', line 131 def get_signing_params(client, expires, headers_to_sign) [ ['Algorithm', get_algorithm_id], ['Credentials', "#{client[:api_key_id]}/#{short_date(@current_time)}/#{@credential_scope}"], ['Date', long_date(@current_time)], ['Expires', expires.to_s], ['SignedHeaders', headers_to_sign.join(';')], ].map { |k, v| query_pair(k, v) } end |
#get_string_to_sign(canonicalized_req) ⇒ Object
228 229 230 231 232 233 234 235 |
# File 'lib/escher.rb', line 228 def get_string_to_sign(canonicalized_req) [ get_algorithm_id, long_date(@current_time), short_date(@current_time) + '/' + @credential_scope, create_algo.new.hexdigest(canonicalized_req) ].join("\n") end |
#is_date_within_range?(request_date, expires) ⇒ Boolean
256 257 258 |
# File 'lib/escher.rb', line 256 def is_date_within_range?(request_date, expires) (request_date - @clock_skew .. request_date + expires + @clock_skew).cover? @current_time end |
#is_valid?(*args) ⇒ Boolean
42 43 44 45 46 47 48 49 |
# File 'lib/escher.rb', line 42 def is_valid?(*args) begin validate(*args) return true rescue return false end end |
#long_date(date) ⇒ Object
248 249 250 |
# File 'lib/escher.rb', line 248 def long_date(date) date.utc.strftime('%Y%m%dT%H%M%SZ') end |
#normalize_white_spaces(value) ⇒ Object
291 292 293 294 295 296 |
# File 'lib/escher.rb', line 291 def normalize_white_spaces(value) value.strip.split('"', -1).map.with_index { |piece, index| is_inside_of_quotes = (index % 2 === 1) is_inside_of_quotes ? piece : piece.gsub(/\s+/, ' ') }.join '"' end |
#parse_algo(algorithm) ⇒ Object
264 265 266 267 |
# File 'lib/escher.rb', line 264 def parse_algo(algorithm) m = /^#{@algo_prefix}-HMAC-(?<algo>[A-Z0-9\,]+)$/.match(algorithm) m && m['algo'] end |
#parse_query(query) ⇒ Object
221 222 223 224 225 226 |
# File 'lib/escher.rb', line 221 def parse_query(query) (query || '') .split('&', -1) .map { |pair| pair.split('=', -1) } .map { |k, v| (k.include?' ') ? [k.str(/\S+/), ''] : [k, v] } end |
#parse_uri(request_uri) ⇒ Object
216 217 218 219 |
# File 'lib/escher.rb', line 216 def parse_uri(request_uri) path, query = request_uri.split '?', 2 return path, parse_query(query) end |
#prepare_headers_to_sign(headers_to_sign) ⇒ Object
212 213 214 |
# File 'lib/escher.rb', line 212 def prepare_headers_to_sign(headers_to_sign) headers_to_sign.sort.uniq.join(';') end |
#query_key_for(key) ⇒ Object
145 146 147 |
# File 'lib/escher.rb', line 145 def query_key_for(key) "X-#{@vendor_key}-#{key}" end |
#query_key_truncate(key) ⇒ Object
149 150 151 |
# File 'lib/escher.rb', line 149 def query_key_truncate(key) key[@vendor_key.length + 3..-1] end |
#query_pair(k, v) ⇒ Object
141 142 143 |
# File 'lib/escher.rb', line 141 def query_pair(k, v) [query_key_for(k), v] end |
#reconfig(algorithm, credential_scope, date) ⇒ Object
93 94 95 96 97 98 99 100 101 102 103 |
# File 'lib/escher.rb', line 93 def reconfig(algorithm, credential_scope, date) Escher.new( credential_scope, algo_prefix: @algo_prefix, vendor_key: @vendor_key, hash_algo: algorithm, auth_header_name: @auth_header_name, date_header_name: @date_header_name, current_time: date ) end |
#short_date(date) ⇒ Object
252 253 254 |
# File 'lib/escher.rb', line 252 def short_date(date) date.utc.strftime('%Y%m%d') end |
#sign!(request, client) ⇒ Object
24 25 26 27 28 29 30 |
# File 'lib/escher.rb', line 24 def sign!(request, client) uri_parsed = URI.parse(request.path) request['Host'] = uri_parsed.host # TODO: we shouldn't remove port from Host here request[@date_header_name] = format_date_for_header request[@auth_header_name] = generate_auth_header(client, request.method, uri_parsed.host, uri_parsed.path, request.body || '', request.to_enum.to_a, []) request end |
#uri_decode(component) ⇒ Object
322 323 324 |
# File 'lib/escher.rb', line 322 def uri_decode(component) Addressable::URI.unencode_component(component) end |
#uri_encode(component) ⇒ Object
318 319 320 |
# File 'lib/escher.rb', line 318 def uri_encode(component) Addressable::URI.encode_component(component, Addressable::URI::CharacterClasses::UNRESERVED) end |
#validate(request, key_db) ⇒ Object
32 33 34 35 36 37 38 39 40 |
# File 'lib/escher.rb', line 32 def validate(request, key_db) headers = [] request.header.each { |key, values| values.each { |value| headers += [[ key, value ]] } } validate_request(key_db, request.request_method, request.path, request.body, headers) end |
#validate_headers(headers, using_query_string_for_validation) ⇒ Object
87 88 89 90 91 |
# File 'lib/escher.rb', line 87 def validate_headers(headers, using_query_string_for_validation) (['Host'] + (using_query_string_for_validation ? [] : [@auth_header_name, @date_header_name])).each do |header| raise EscherError, 'Missing header: ' + header unless get_header(header, headers) end end |
#validate_request(key_db, method, request_uri, body, headers) ⇒ Object
51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 |
# File 'lib/escher.rb', line 51 def validate_request(key_db, method, request_uri, body, headers) path, query_parts = parse_uri(request_uri) signature_from_query = get_signing_param('Signature', query_parts) validate_headers(headers, signature_from_query) if method == 'GET' && signature_from_query raw_date = get_signing_param('Date', query_parts) algorithm, api_key_id, short_date, credential_scope, signed_headers, signature, expires = get_auth_parts_from_query(query_parts) body = 'UNSIGNED-PAYLOAD' query_parts.delete [query_key_for('Signature'), signature] query_parts = query_parts.map { |k, v| [uri_decode(k), uri_decode(v)] } else raw_date = get_header(@date_header_name, headers) auth_header = get_header(@auth_header_name, headers) algorithm, api_key_id, short_date, credential_scope, signed_headers, signature, expires = get_auth_parts_from_header(auth_header) end date = Time.parse(raw_date) api_secret = key_db[api_key_id] raise EscherError, 'Invalid API key' unless api_secret raise EscherError, 'Only SHA256 and SHA512 hash algorithms are allowed' unless %w(SHA256 SHA512).include?(algorithm) raise EscherError, 'Invalid request date' unless short_date(date) == short_date raise EscherError, 'The request date is not within the accepted time range' unless is_date_within_range?(date, expires) raise EscherError, 'Invalid credentials' unless credential_scope == @credential_scope raise EscherError, 'Host header is not signed' unless signed_headers.include? 'host' raise EscherError, 'Only the host header should be signed' if signature_from_query && signed_headers != ['host'] raise EscherError, 'Date header is not signed' if !signature_from_query && !signed_headers.include?(@date_header_name.downcase) escher = reconfig(algorithm, credential_scope, date) expected_signature = escher.generate_signature(api_secret, body, headers, method, signed_headers, path, query_parts) raise EscherError, 'The signatures do not match' unless signature == expected_signature end |