Module: Altcha

Defined in:
lib/altcha.rb,
lib/altcha/version.rb

Overview

Altcha module provides functions for creating and verifying ALTCHA challenges.

Defined Under Namespace

Modules: Algorithm Classes: Challenge, ChallengeOptions, Payload, ServerSignaturePayload, ServerSignatureVerificationData, Solution

Constant Summary collapse

DEFAULT_MAX_NUMBER =

Default values for challenge generation.

1_000_000
DEFAULT_SALT_LENGTH =
12
DEFAULT_ALGORITHM =
Algorithm::SHA256
VERSION =
'0.2.1'

Class Method Summary collapse

Class Method Details

.create_challenge(options) ⇒ Challenge

Creates a challenge for the client to solve based on the provided options.

Parameters:

Returns:

  • (Challenge)

    The generated Challenge object.



247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
# File 'lib/altcha.rb', line 247

def self.create_challenge(options)
  algorithm = options.algorithm || DEFAULT_ALGORITHM
  max_number = options.max_number || DEFAULT_MAX_NUMBER
  salt_length = options.salt_length || DEFAULT_SALT_LENGTH

  params = options.params || {}
  params['expires'] = options.expires.to_i if options.expires

  salt = options.salt || random_bytes(salt_length).unpack1('H*')
  salt += "?#{URI.encode_www_form(params)}" unless params.empty?

  number = options.number || random_int(max_number)

  challenge_str = "#{salt}#{number}"
  challenge = hash_hex(algorithm, challenge_str)
  signature = hmac_hex(algorithm, challenge, options.hmac_key)

  Challenge.new(
    algorithm: algorithm,
    challenge: challenge,
    maxnumber: max_number,
    salt: salt,
    signature: signature,
  )
end

.extract_params(payload) ⇒ Hash

Extracts parameters from the payload’s salt.

Parameters:

  • payload (Payload)

    The payload containing the salt.

Returns:

  • (Hash)

    Parameters extracted from the payload’s salt.



331
332
333
# File 'lib/altcha.rb', line 331

def self.extract_params(payload)
  URI.decode_www_form(payload.salt.split('?').last).to_h
end

.hash(algorithm, data) ⇒ String

Hashes the input data using the specified algorithm.

Parameters:

  • algorithm (String)

    The hashing algorithm to use (e.g., SHA-1, SHA-256, SHA-512).

  • data (String)

    The data to hash.

Returns:

  • (String)

    The binary hash of the data.

Raises:

  • (ArgumentError)

    If an unsupported algorithm is specified.



201
202
203
204
205
206
207
208
209
210
211
212
# File 'lib/altcha.rb', line 201

def self.hash(algorithm, data)
  case algorithm
  when Algorithm::SHA1
    OpenSSL::Digest::SHA1.digest(data)
  when Algorithm::SHA256
    OpenSSL::Digest::SHA256.digest(data)
  when Algorithm::SHA512
    OpenSSL::Digest::SHA512.digest(data)
  else
    raise ArgumentError, "Unsupported algorithm: #{algorithm}"
  end
end

.hash_hex(algorithm, data) ⇒ String

Hashes the input data using the specified algorithm and returns the hexadecimal representation of the hash.

Parameters:

  • algorithm (String)

    The hashing algorithm to use (e.g., SHA-1, SHA-256, SHA-512).

  • data (String)

    The data to hash.

Returns:

  • (String)

    The hexadecimal representation of the hashed data.



191
192
193
194
# File 'lib/altcha.rb', line 191

def self.hash_hex(algorithm, data)
  hash = hash(algorithm, data)
  hash.unpack1('H*')
end

.hmac_hash(algorithm, data, key) ⇒ String

Computes the HMAC of the input data using the specified algorithm and key.

Parameters:

  • algorithm (String)

    The hashing algorithm to use (e.g., SHA-1, SHA-256, SHA-512).

  • data (String)

    The data to hash.

  • key (String)

    The key for the HMAC.

Returns:

  • (String)

    The binary HMAC of the data.

Raises:

  • (ArgumentError)

    If an unsupported algorithm is specified.



230
231
232
233
234
235
236
237
238
239
240
241
242
# File 'lib/altcha.rb', line 230

def self.hmac_hash(algorithm, data, key)
  digest_class = case algorithm
                 when Algorithm::SHA1
                   OpenSSL::Digest::SHA1
                 when Algorithm::SHA256
                   OpenSSL::Digest::SHA256
                 when Algorithm::SHA512
                   OpenSSL::Digest::SHA512
                 else
                   raise ArgumentError, "Unsupported algorithm: #{algorithm}"
                 end
  OpenSSL::HMAC.digest(digest_class.new, key, data)
end

.hmac_hex(algorithm, data, key) ⇒ String

Computes the HMAC of the input data using the specified algorithm and key, and returns the hexadecimal representation.

Parameters:

  • algorithm (String)

    The hashing algorithm to use (e.g., SHA-1, SHA-256, SHA-512).

  • data (String)

    The data to hash.

  • key (String)

    The key for the HMAC.

Returns:

  • (String)

    The hexadecimal representation of the HMAC.



219
220
221
222
# File 'lib/altcha.rb', line 219

def self.hmac_hex(algorithm, data, key)
  hmac = hmac_hash(algorithm, data, key)
  hmac.unpack1('H*')
end

.random_bytes(length) ⇒ String

Generates a random byte array of the specified length.

Parameters:

  • length (Integer)

    The length of the byte array to generate.

Returns:

  • (String)

    The generated random byte array.



176
177
178
# File 'lib/altcha.rb', line 176

def self.random_bytes(length)
  OpenSSL::Random.random_bytes(length)
end

.random_int(max) ⇒ Integer

Generates a random integer between 0 and the specified maximum (inclusive).

Parameters:

  • max (Integer)

    The upper bound for the random integer.

Returns:

  • (Integer)

    The generated random integer.



183
184
185
# File 'lib/altcha.rb', line 183

def self.random_int(max)
  rand(max + 1)
end

.solve_challenge(challenge, salt, algorithm, max, start) ⇒ Solution?

Solves a challenge by iterating over possible solutions.

Parameters:

  • challenge (String)

    The challenge to solve.

  • salt (String)

    The salt used in the challenge.

  • algorithm (String)

    The hashing algorithm used.

  • max (Integer)

    The maximum number to try.

  • start (Integer)

    The starting number to try.

Returns:

  • (Solution, nil)

    The solution if found, or nil if not.



416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
# File 'lib/altcha.rb', line 416

def self.solve_challenge(challenge, salt, algorithm, max, start)
  algorithm ||= Algorithm::SHA256
  max ||= DEFAULT_MAX_NUMBER
  start ||= 0

  start_time = Time.now

  (start..max).each do |n|
    hash = hash_hex(algorithm, "#{salt}#{n}")
    if hash == challenge
      return Solution.new.tap do |s|
        s.number = n
        s.took = Time.now - start_time
      end
    end
  end

  nil
end

.verify_fields_hash(form_data, fields, fields_hash, algorithm) ⇒ Boolean

Verifies the hash of form fields.

Parameters:

  • form_data (Hash)

    The form data to verify.

  • fields (Array<String>)

    The fields to include in the hash.

  • fields_hash (String)

    The expected hash of the fields.

  • algorithm (String)

    The hashing algorithm to use.

Returns:

  • (Boolean)

    True if the fields hash matches, false otherwise.



341
342
343
344
345
346
# File 'lib/altcha.rb', line 341

def self.verify_fields_hash(form_data, fields, fields_hash, algorithm)
  lines = fields.map { |field| form_data[field].to_s }
  joined_data = lines.join("\n")
  computed_hash = hash_hex(algorithm, joined_data)
  computed_hash == fields_hash
end

.verify_server_signature(payload, hmac_key) ⇒ Array<Boolean, ServerSignatureVerificationData>

Verifies the server’s signature.

Parameters:

  • payload (String, ServerSignaturePayload)

    The payload to verify, either as a base64 encoded JSON string or a ServerSignaturePayload instance.

  • hmac_key (String)

    The key used for HMAC verification.

Returns:

  • (Array<Boolean, ServerSignatureVerificationData>)

    A tuple where the first element is true if the signature is valid, and the second element is the verification data.



352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
# File 'lib/altcha.rb', line 352

def self.verify_server_signature(payload, hmac_key)
  # Decode and parse base64 JSON string if it's a String
  if payload.is_a?(String)
    decoded_payload = Base64.decode64(payload)
    payload = ServerSignaturePayload.from_json(decoded_payload)

  # Convert payload from hash to ServerSignaturePayload if it's a plain object
  elsif payload.is_a?(Hash)
    payload = ServerSignaturePayload.new(
      algorithm: payload[:algorithm],
      verification_data: payload[:verification_data],
      signature: payload[:signature],
      verified: payload[:verified]
    )
  end

  # Ensure payload is an instance of ServerSignaturePayload
  return [false, nil] unless payload.is_a?(ServerSignaturePayload)

  required_attributes = %i[algorithm verification_data signature verified]
  required_attributes.each do |attr|
    value = payload.send(attr)
    return false if value.nil? || value.to_s.strip.empty?
  end

  hash_data = hash(payload.algorithm, payload.verification_data)
  expected_signature = hmac_hex(payload.algorithm, hash_data, hmac_key)
  
  params = URI.decode_www_form(payload.verification_data).to_h
  verification_data = ServerSignatureVerificationData.new.tap do |v|
    v.classification = params['classification'] || nil
    v.country = params['country'] || nil
    v.detected_language = params['detectedLanguage'] || nil
    v.email = params['email'] || nil
    v.expire = params['expire'] ? params['expire'].to_i : nil
    v.fields = params['fields'] ? params['fields'].split(',') : nil
    v.fields_hash = params['fieldsHash'] || nil
    v.ip_address = params['ipAddress'] || nil
    v.reasons = params['reasons'] ? params['reasons'].split(',') : nil
    v.score = params['score'] ? params['score'].to_f : nil
    v.time = params['time'] ? params['time'].to_i : nil
    v.verified = params['verified'] == 'true'
  end

  now = Time.now.to_i
  is_verified = payload.verified &&
                verification_data.verified &&
                (verification_data.expire.nil? || verification_data.expire > now) &&
                payload.signature == expected_signature

  [is_verified, verification_data]
rescue ArgumentError, JSON::ParserError => e
  # Handle specific exceptions for invalid Base64 or JSON
  puts "Error decoding or parsing payload: #{e.message}"
  false
end

.verify_solution(payload, hmac_key, check_expires = true) ⇒ Boolean

Verifies the solution provided by the client.

Parameters:

  • payload (String, Payload)

    The payload to verify, either as a base64 encoded JSON string or a Payload instance.

  • hmac_key (String)

    The key used for HMAC verification.

  • check_expires (Boolean) (defaults to: true)

    Whether to check if the challenge has expired.

Returns:

  • (Boolean)

    True if the solution is valid, false otherwise.



278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
# File 'lib/altcha.rb', line 278

def self.verify_solution(payload, hmac_key, check_expires = true)
  # Attempt to handle payload as a base64 encoded JSON string or as a Payload instance

  # Decode and parse base64 JSON string if it's a String
  if payload.is_a?(String)
    decoded_payload = Base64.decode64(payload)
    payload = Payload.from_json(decoded_payload)
  
  # Convert payload from hash to Payload if it's a plain object
  elsif payload.is_a?(Hash)
    payload = Payload.new(
      algorithm: payload[:algorithm],
      challenge: payload[:challenge],
      number: payload[:number],
      salt: payload[:salt],
      signature: payload[:signature]
    )
  end

  # Ensure payload is an instance of Payload
  return false unless payload.is_a?(Payload)

  required_attributes = %i[algorithm challenge number salt signature]
  required_attributes.each do |attr|
    value = payload.send(attr)
    return false if value.nil? || value.to_s.strip.empty?
  end

  # Extract expiration time if checking expiration
  if check_expires && payload.salt.include?('?')
    expires = URI.decode_www_form(payload.salt.split('?').last).to_h['expires'].to_i
    return false if expires && Time.now.to_i > expires
  end

  # Convert payload to ChallengeOptions
  challenge_options = ChallengeOptions.new(
    algorithm: payload.algorithm,
    hmac_key: hmac_key,
    number: payload.number,
    salt: payload.salt
  )

  # Create expected challenge and compare with the provided payload
  expected_challenge = create_challenge(challenge_options)
  expected_challenge.challenge == payload.challenge && expected_challenge.signature == payload.signature
rescue ArgumentError, JSON::ParserError
  # Handle specific exceptions for invalid Base64 or JSON
  false
end