altcha 1.0.0 → 2.0.0

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.
data/altcha.gemspec CHANGED
@@ -12,6 +12,7 @@ Gem::Specification.new do |spec|
12
12
  spec.description = "A lightweight library for creating and verifying ALTCHA challenges."
13
13
  spec.homepage = "https://altcha.org"
14
14
  spec.license = "MIT"
15
+ spec.required_ruby_version = ">= 2.7"
15
16
 
16
17
  # Specify which files should be added to the gem when it is released.
17
18
  # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
@@ -22,7 +23,9 @@ Gem::Specification.new do |spec|
22
23
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
23
24
  spec.require_paths = ["lib"]
24
25
 
25
- spec.add_development_dependency "bundler", "~> 4.0.1"
26
- spec.add_development_dependency "rake", "~> 13.3.1"
27
- spec.add_development_dependency "rspec", "~> 3.0"
26
+ spec.add_dependency "base64"
27
+
28
+ spec.add_development_dependency "bundler", "~> 4.0"
29
+ spec.add_development_dependency "rake", "~> 13.3"
30
+ spec.add_development_dependency "rspec", "~> 3.13"
28
31
  end
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Basic HTTP server demonstrating ALTCHA v2 challenge/verify flow.
4
+ #
5
+ # Usage:
6
+ # HMAC_SECRET=your-secret ruby examples/server.rb
7
+ #
8
+ # Endpoints:
9
+ # GET /challenge — issues a new challenge (JSON)
10
+ # POST /submit — verifies an altcha payload from a form or JSON body
11
+ #
12
+ # Requires:
13
+ # gem install webrick
14
+
15
+ $LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
16
+
17
+ require 'webrick'
18
+ require 'json'
19
+ require 'base64'
20
+ require 'securerandom'
21
+ require 'uri'
22
+ require 'altcha'
23
+
24
+ HMAC_SECRET = ENV.fetch('HMAC_SECRET', 'change-me-in-production')
25
+ HMAC_KEY_SECRET = ENV.fetch('HMAC_KEY_SECRET', 'change-me-in-production')
26
+ PORT = ENV.fetch('PORT', 3000).to_i
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Helpers
30
+ # ---------------------------------------------------------------------------
31
+
32
+ def cors_headers(response)
33
+ response['Access-Control-Allow-Origin'] = '*'
34
+ response['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS'
35
+ response['Access-Control-Allow-Headers'] = 'Content-Type'
36
+ end
37
+
38
+ def json_response(response, status, body)
39
+ response.status = status
40
+ response['Content-Type'] = 'application/json'
41
+ response.body = body.to_json
42
+ end
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Server
46
+ # ---------------------------------------------------------------------------
47
+
48
+ server = WEBrick::HTTPServer.new(
49
+ Port: PORT,
50
+ Logger: WEBrick::Log.new($stdout, WEBrick::Log::INFO),
51
+ AccessLog: [[
52
+ $stdout,
53
+ '%{%Y-%m-%dT%H:%M:%S}t %m %U %s'
54
+ ]]
55
+ )
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # GET /challenge
59
+ # ---------------------------------------------------------------------------
60
+
61
+ server.mount_proc '/challenge' do |req, res|
62
+ cors_headers(res)
63
+
64
+ if req.request_method == 'OPTIONS'
65
+ res.status = 204
66
+ next
67
+ end
68
+
69
+ unless req.request_method == 'GET'
70
+ json_response(res, 405, { error: 'Method not allowed' })
71
+ next
72
+ end
73
+
74
+ options = Altcha::V2::CreateChallengeOptions.new(
75
+ algorithm: 'PBKDF2/SHA-256',
76
+ cost: 5_000,
77
+ counter: SecureRandom.random_number(5_000..10_000),
78
+ expires_at: Time.now + 300, # 5 minutes
79
+ hmac_signature_secret: HMAC_SECRET,
80
+ hmac_key_signature_secret: HMAC_KEY_SECRET
81
+ )
82
+
83
+ challenge = Altcha::V2.create_challenge(options)
84
+ json_response(res, 200, challenge.to_h)
85
+ end
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # POST /submit
89
+ # ---------------------------------------------------------------------------
90
+
91
+ server.mount_proc '/submit' do |req, res|
92
+ cors_headers(res)
93
+
94
+ if req.request_method == 'OPTIONS'
95
+ res.status = 204
96
+ next
97
+ end
98
+
99
+ unless req.request_method == 'POST'
100
+ json_response(res, 405, { error: 'Method not allowed' })
101
+ next
102
+ end
103
+
104
+ # Parse the request body based on Content-Type.
105
+ content_type = req['Content-Type'].to_s
106
+ form_data = {}
107
+ altcha_value = nil
108
+
109
+ begin
110
+ if content_type.include?('application/json')
111
+ parsed = JSON.parse(req.body || '{}')
112
+ altcha_value = parsed.delete('altcha')
113
+ form_data = parsed
114
+
115
+ elsif content_type.include?('application/x-www-form-urlencoded')
116
+ parsed = URI.decode_www_form(req.body || '').to_h
117
+ altcha_value = parsed.delete('altcha')
118
+ form_data = parsed
119
+
120
+ else
121
+ json_response(res, 415, { error: 'Unsupported content type' })
122
+ next
123
+ end
124
+ rescue JSON::ParserError
125
+ json_response(res, 400, { error: 'Invalid JSON body' })
126
+ next
127
+ end
128
+
129
+ if altcha_value.nil? || altcha_value.empty?
130
+ json_response(res, 400, { error: 'Missing altcha field' })
131
+ next
132
+ end
133
+
134
+ # Decode and verify the ALTCHA payload.
135
+ # Detect type by presence of 'verificationData' (server signature) vs 'solution' (client payload).
136
+ begin
137
+ decoded = JSON.parse(Base64.decode64(altcha_value))
138
+
139
+ result = if decoded.key?('verificationData')
140
+ Altcha::V2.verify_server_signature(
141
+ payload: Altcha::V2::ServerSignaturePayload.from_h(decoded),
142
+ hmac_secret: HMAC_SECRET
143
+ )
144
+ else
145
+ payload = Altcha::V2::Payload.new(
146
+ challenge: Altcha::V2::Challenge.from_h(decoded['challenge']),
147
+ solution: Altcha::V2::Solution.new(
148
+ counter: decoded['solution']['counter'],
149
+ derived_key: decoded['solution']['derivedKey']
150
+ )
151
+ )
152
+ Altcha::V2.verify_solution(
153
+ payload.challenge,
154
+ payload.solution,
155
+ hmac_signature_secret: HMAC_SECRET,
156
+ hmac_key_signature_secret: HMAC_KEY_SECRET
157
+ )
158
+ end
159
+ rescue StandardError => e
160
+ json_response(res, 400, { error: "Invalid altcha payload: #{e.message}" })
161
+ next
162
+ end
163
+
164
+ altcha_result = {
165
+ verified: result.verified,
166
+ expired: result.expired,
167
+ invalid_signature: result.invalid_signature,
168
+ invalid_solution: result.invalid_solution,
169
+ time: result.time
170
+ }
171
+ altcha_result[:verification_data] = result.verification_data if result.respond_to?(:verification_data)
172
+
173
+ unless result.verified
174
+ reason = if result.expired
175
+ 'Challenge expired'
176
+ elsif result.invalid_signature
177
+ 'Invalid challenge signature'
178
+ else
179
+ 'Incorrect solution'
180
+ end
181
+ json_response(res, 400, { error: reason, altcha: altcha_result })
182
+ next
183
+ end
184
+
185
+ # The form data is now trusted. Process it here.
186
+ $stdout.puts "Verified submission: #{form_data}"
187
+
188
+ json_response(res, 200, { success: true, received: form_data, altcha: altcha_result })
189
+ end
190
+
191
+ # ---------------------------------------------------------------------------
192
+ # Start
193
+ # ---------------------------------------------------------------------------
194
+
195
+ trap('INT') { server.shutdown }
196
+ trap('TERM') { server.shutdown }
197
+
198
+ $stdout.puts "Listening on http://localhost:#{PORT}"
199
+ $stdout.puts "HMAC_SECRET: #{HMAC_SECRET == 'change-me-in-production' ? '(default — set HMAC_SECRET env var)' : '(set)'}"
200
+ $stdout.puts "HMAC_KEY_SECRET: #{HMAC_KEY_SECRET == 'change-me-in-production' ? '(default — set HMAC_KEY_SECRET env var)' : '(set)'}"
201
+ server.start
data/lib/altcha/v1.rb ADDED
@@ -0,0 +1,336 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require 'base64'
5
+ require 'json'
6
+ require 'uri'
7
+ require 'time'
8
+
9
+ module Altcha
10
+ # V1 proof-of-work: find a number N such that SHA256(salt+N) equals the challenge hash.
11
+ module V1
12
+ # Contains algorithm type definitions for hashing.
13
+ module Algorithm
14
+ SHA1 = 'SHA-1'
15
+ SHA256 = 'SHA-256'
16
+ SHA512 = 'SHA-512'
17
+ end
18
+
19
+ DEFAULT_MAX_NUMBER = 1_000_000
20
+ DEFAULT_SALT_LENGTH = 12
21
+ DEFAULT_ALGORITHM = Algorithm::SHA256
22
+
23
+ # Options for generating a challenge.
24
+ class ChallengeOptions
25
+ attr_accessor :algorithm, :max_number, :salt_length, :hmac_key, :salt, :number, :expires, :params
26
+
27
+ def initialize(algorithm: nil, max_number: nil, salt_length: nil, hmac_key:,
28
+ salt: nil, number: nil, expires: nil, params: nil)
29
+ @algorithm = algorithm
30
+ @max_number = max_number
31
+ @salt_length = salt_length
32
+ @hmac_key = hmac_key
33
+ @salt = salt
34
+ @number = number
35
+ @expires = expires
36
+ @params = params
37
+ end
38
+ end
39
+
40
+ # A challenge sent to the client.
41
+ class Challenge
42
+ attr_accessor :algorithm, :challenge, :maxnumber, :salt, :signature
43
+
44
+ def initialize(algorithm:, challenge:, maxnumber: nil, salt:, signature:)
45
+ @algorithm = algorithm
46
+ @challenge = challenge
47
+ @maxnumber = maxnumber
48
+ @salt = salt
49
+ @signature = signature
50
+ end
51
+
52
+ def to_json(options = {})
53
+ {
54
+ algorithm: @algorithm,
55
+ challenge: @challenge,
56
+ maxnumber: @maxnumber,
57
+ salt: @salt,
58
+ signature: @signature
59
+ }.to_json(options)
60
+ end
61
+ end
62
+
63
+ # The client-submitted solution payload.
64
+ class Payload
65
+ attr_accessor :algorithm, :challenge, :number, :salt, :signature
66
+
67
+ def initialize(algorithm:, challenge:, number:, salt:, signature:)
68
+ @algorithm = algorithm
69
+ @challenge = challenge
70
+ @number = number
71
+ @salt = salt
72
+ @signature = signature
73
+ end
74
+
75
+ def to_json(options = {})
76
+ {
77
+ algorithm: @algorithm,
78
+ challenge: @challenge,
79
+ number: @number,
80
+ salt: @salt,
81
+ signature: @signature
82
+ }.to_json(options)
83
+ end
84
+
85
+ def self.from_json(string)
86
+ data = JSON.parse(string)
87
+ new(
88
+ algorithm: data['algorithm'],
89
+ challenge: data['challenge'],
90
+ number: data['number'],
91
+ salt: data['salt'],
92
+ signature: data['signature']
93
+ )
94
+ end
95
+ end
96
+
97
+ # Payload for server-side signature verification.
98
+ class ServerSignaturePayload
99
+ attr_accessor :algorithm, :verification_data, :signature, :verified
100
+
101
+ def initialize(algorithm:, verification_data:, signature:, verified:)
102
+ @algorithm = algorithm
103
+ @verification_data = verification_data
104
+ @signature = signature
105
+ @verified = verified
106
+ end
107
+
108
+ def to_json(options = {})
109
+ {
110
+ algorithm: @algorithm,
111
+ verificationData: @verification_data,
112
+ signature: @signature,
113
+ verified: @verified
114
+ }.to_json(options)
115
+ end
116
+
117
+ def self.from_json(string)
118
+ data = JSON.parse(string)
119
+ new(
120
+ algorithm: data['algorithm'],
121
+ verification_data: data['verificationData'],
122
+ signature: data['signature'],
123
+ verified: data['verified']
124
+ )
125
+ end
126
+ end
127
+
128
+ # Typed fields returned from verify_server_signature.
129
+ class ServerSignatureVerificationData
130
+ attr_accessor :classification, :country, :detected_language, :email, :expire,
131
+ :fields, :fields_hash, :ip_address, :reasons, :score, :time, :verified
132
+
133
+ def to_json(options = {})
134
+ {
135
+ classification: @classification,
136
+ country: @country,
137
+ detectedLanguage: @detected_language,
138
+ email: @email,
139
+ expire: @expire,
140
+ fields: @fields,
141
+ fieldsHash: @fields_hash,
142
+ ipAddress: @ip_address,
143
+ reasons: @reasons,
144
+ score: @score,
145
+ time: @time,
146
+ verified: @verified
147
+ }.to_json(options)
148
+ end
149
+ end
150
+
151
+ # Result of solve_challenge.
152
+ class Solution
153
+ attr_accessor :number, :took
154
+ end
155
+
156
+ # -------------------------------------------------------------------------
157
+ # Module-level functions
158
+ # -------------------------------------------------------------------------
159
+
160
+ def self.random_bytes(length)
161
+ OpenSSL::Random.random_bytes(length)
162
+ end
163
+
164
+ def self.random_int(max)
165
+ rand(max + 1)
166
+ end
167
+
168
+ def self.hash_hex(algorithm, data)
169
+ hash(algorithm, data).unpack1('H*')
170
+ end
171
+
172
+ def self.hash(algorithm, data)
173
+ case algorithm
174
+ when Algorithm::SHA1 then OpenSSL::Digest::SHA1.digest(data)
175
+ when Algorithm::SHA256 then OpenSSL::Digest::SHA256.digest(data)
176
+ when Algorithm::SHA512 then OpenSSL::Digest::SHA512.digest(data)
177
+ else raise ArgumentError, "Unsupported algorithm: #{algorithm}"
178
+ end
179
+ end
180
+
181
+ def self.hmac_hex(algorithm, data, key)
182
+ hmac_hash(algorithm, data, key).unpack1('H*')
183
+ end
184
+
185
+ def self.hmac_hash(algorithm, data, key)
186
+ digest_class = case algorithm
187
+ when Algorithm::SHA1 then OpenSSL::Digest::SHA1
188
+ when Algorithm::SHA256 then OpenSSL::Digest::SHA256
189
+ when Algorithm::SHA512 then OpenSSL::Digest::SHA512
190
+ else raise ArgumentError, "Unsupported algorithm: #{algorithm}"
191
+ end
192
+ OpenSSL::HMAC.digest(digest_class.new, key, data)
193
+ end
194
+
195
+ def self.create_challenge(options)
196
+ algorithm = options.algorithm || DEFAULT_ALGORITHM
197
+ max_number = options.max_number || DEFAULT_MAX_NUMBER
198
+ salt_length = options.salt_length || DEFAULT_SALT_LENGTH
199
+
200
+ params = options.params || {}
201
+ params['expires'] = options.expires.to_i if options.expires
202
+
203
+ salt = options.salt || random_bytes(salt_length).unpack1('H*')
204
+ salt += "?#{URI.encode_www_form(params)}" unless params.empty?
205
+ salt += salt.end_with?('&') ? '' : '&'
206
+
207
+ number = options.number || random_int(max_number)
208
+ challenge = hash_hex(algorithm, "#{salt}#{number}")
209
+ signature = hmac_hex(algorithm, challenge, options.hmac_key)
210
+
211
+ Challenge.new(
212
+ algorithm: algorithm,
213
+ challenge: challenge,
214
+ maxnumber: max_number,
215
+ salt: salt,
216
+ signature: signature
217
+ )
218
+ end
219
+
220
+ def self.verify_solution(payload, hmac_key, check_expires = true)
221
+ if payload.is_a?(String)
222
+ payload = Payload.from_json(Base64.decode64(payload))
223
+ elsif payload.is_a?(Hash)
224
+ payload = Payload.new(
225
+ algorithm: payload[:algorithm],
226
+ challenge: payload[:challenge],
227
+ number: payload[:number],
228
+ salt: payload[:salt],
229
+ signature: payload[:signature]
230
+ )
231
+ end
232
+
233
+ return false unless payload.is_a?(Payload)
234
+
235
+ %i[algorithm challenge number salt signature].each do |attr|
236
+ value = payload.send(attr)
237
+ return false if value.nil? || value.to_s.strip.empty?
238
+ end
239
+
240
+ if check_expires && payload.salt.include?('?')
241
+ expires = URI.decode_www_form(payload.salt.split('?').last).to_h['expires'].to_i
242
+ return false if expires && Time.now.to_i > expires
243
+ end
244
+
245
+ expected = create_challenge(
246
+ ChallengeOptions.new(
247
+ algorithm: payload.algorithm,
248
+ hmac_key: hmac_key,
249
+ number: payload.number,
250
+ salt: payload.salt
251
+ )
252
+ )
253
+ expected.challenge == payload.challenge && expected.signature == payload.signature
254
+ rescue ArgumentError, JSON::ParserError
255
+ false
256
+ end
257
+
258
+ def self.extract_params(payload)
259
+ URI.decode_www_form(payload.salt.split('?').last).to_h
260
+ end
261
+
262
+ def self.verify_fields_hash(form_data, fields, fields_hash, algorithm)
263
+ lines = fields.map { |field| form_data[field].to_s }
264
+ joined_data = lines.join("\n")
265
+ hash_hex(algorithm, joined_data) == fields_hash
266
+ end
267
+
268
+ def self.verify_server_signature(payload, hmac_key)
269
+ if payload.is_a?(String)
270
+ payload = ServerSignaturePayload.from_json(Base64.decode64(payload))
271
+ elsif payload.is_a?(Hash)
272
+ payload = ServerSignaturePayload.new(
273
+ algorithm: payload[:algorithm],
274
+ verification_data: payload[:verification_data],
275
+ signature: payload[:signature],
276
+ verified: payload[:verified]
277
+ )
278
+ end
279
+
280
+ return [false, nil] unless payload.is_a?(ServerSignaturePayload)
281
+
282
+ %i[algorithm verification_data signature verified].each do |attr|
283
+ value = payload.send(attr)
284
+ return false if value.nil? || value.to_s.strip.empty?
285
+ end
286
+
287
+ hash_data = hash(payload.algorithm, payload.verification_data)
288
+ expected_signature = hmac_hex(payload.algorithm, hash_data, hmac_key)
289
+
290
+ params = URI.decode_www_form(payload.verification_data).to_h
291
+ verification_data = ServerSignatureVerificationData.new.tap do |v|
292
+ v.classification = params['classification']
293
+ v.country = params['country']
294
+ v.detected_language = params['detectedLanguage']
295
+ v.email = params['email']
296
+ v.expire = params['expire']&.to_i
297
+ v.fields = params['fields']&.split(',')
298
+ v.fields_hash = params['fieldsHash']
299
+ v.ip_address = params['ipAddress']
300
+ v.reasons = params['reasons']&.split(',')
301
+ v.score = params['score']&.to_f
302
+ v.time = params['time']&.to_i
303
+ v.verified = params['verified'] == 'true'
304
+ end
305
+
306
+ now = Time.now.to_i
307
+ is_verified = payload.verified &&
308
+ verification_data.verified &&
309
+ (verification_data.expire.nil? || verification_data.expire > now) &&
310
+ payload.signature == expected_signature
311
+
312
+ [is_verified, verification_data]
313
+ rescue ArgumentError, JSON::ParserError => e
314
+ puts "Error decoding or parsing payload: #{e.message}"
315
+ false
316
+ end
317
+
318
+ def self.solve_challenge(challenge, salt, algorithm, max, start)
319
+ algorithm ||= DEFAULT_ALGORITHM
320
+ max ||= DEFAULT_MAX_NUMBER
321
+ start ||= 0
322
+ start_time = Time.now
323
+
324
+ (start..max).each do |n|
325
+ if hash_hex(algorithm, "#{salt}#{n}") == challenge
326
+ return Solution.new.tap do |s|
327
+ s.number = n
328
+ s.took = Time.now - start_time
329
+ end
330
+ end
331
+ end
332
+
333
+ nil
334
+ end
335
+ end
336
+ end