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.1.0'

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.



190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
# File 'lib/altcha.rb', line 190

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.tap do |c|
    c.algorithm = algorithm
    c.challenge = challenge
    c.maxnumber = max_number
    c.salt = salt
    c.signature = signature
  end
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.



264
265
266
# File 'lib/altcha.rb', line 264

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.



144
145
146
147
148
149
150
151
152
153
154
155
# File 'lib/altcha.rb', line 144

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.



134
135
136
137
# File 'lib/altcha.rb', line 134

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.



173
174
175
176
177
178
179
180
181
182
183
184
185
# File 'lib/altcha.rb', line 173

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.



162
163
164
165
# File 'lib/altcha.rb', line 162

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.



119
120
121
# File 'lib/altcha.rb', line 119

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.



126
127
128
# File 'lib/altcha.rb', line 126

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.



338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
# File 'lib/altcha.rb', line 338

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.



274
275
276
277
278
279
# File 'lib/altcha.rb', line 274

def self.verify_fields_hash(form_data, fields, fields_hash, algorithm)
  lines = fields.map { |field| form_data[field].to_a.first.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.



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
327
328
329
# File 'lib/altcha.rb', line 285

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 = JSON.parse(decoded_payload, object_class: ServerSignaturePayload)
  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.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.



221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
# File 'lib/altcha.rb', line 221

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 = JSON.parse(decoded_payload, object_class: Payload)
  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.tap do |co|
    co.algorithm = payload.algorithm
    co.hmac_key = hmac_key
    co.number = payload.number
    co.salt = payload.salt
  end

  # 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