Class: Escher

Inherits:
Object
  • Object
show all
Defined in:
lib/escher.rb,
lib/escher/version.rb

Constant Summary collapse

VERSION =
"0.0.5"

Instance Method Summary collapse

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, options)
  @credential_scope = credential_scope
  @algo_prefix      = options[:algo_prefix]      || 'ESR'
  @vendor_key       = options[:vendor_key]       || 'Escher'
  @hash_algo        = options[:hash_algo]        || 'SHA256'
  @current_time     = options[:current_time]     || Time.now
  @auth_header_name = options[:auth_header_name] || 'X-Escher-Auth'
  @date_header_name = options[:date_header_name] || 'X-Escher-Date'
  @clock_skew       = options[: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_algoObject



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_headerObject



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_idObject



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

Raises:



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

Returns:

  • (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

Returns:

  • (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

Raises:



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