altcha 0.2.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/SECURITY.md ADDED
@@ -0,0 +1,57 @@
1
+ ## Security Vulnerability Disclosure Policy
2
+
3
+ We take security seriously and value the contributions of researchers who act in good faith to help protect our users. If you believe you have found a vulnerability in our services or software, we encourage you to report it responsibly so we can address it promptly.
4
+
5
+ ### Reporting a Vulnerability
6
+
7
+ Please email your findings to our [security contacts](https://altcha.org/contact#reporting-security-issues).
8
+ To ensure confidentiality, we recommend encrypting your report using our [PGP key](https://altcha.org/pgp/security-public-key.asc).
9
+
10
+ Your report should include:
11
+
12
+ - A clear description of the vulnerability.
13
+ - A working proof of concept or detailed steps to reproduce the issue.
14
+ - Relevant logs, screenshots, or code snippets.
15
+
16
+ We will acknowledge receipt of your report within 5 business days and keep you informed about our investigation.
17
+
18
+ ### In-Scope
19
+
20
+ We prioritize reports that demonstrate a real, actionable security risk to our software, services, or infrastructure, such as:
21
+
22
+ - Remote code execution (RCE)
23
+ - Authentication bypass or privilege escalation
24
+ - Server-side request forgery (SSRF)
25
+ - Cross-site scripting (XSS) or CSRF with significant impact
26
+
27
+ ### Out-of-Scope
28
+
29
+ To minimize automated noise, the following are generally excluded from our review process:
30
+
31
+ - Automated results: Reports generated by scanners that lack a manual, actionable proof of exploitability.
32
+ - Configuration & Hygiene: Missing HTTP headers, TLS/SSL configurations, DNS records, or server banners.
33
+ - Volume-based attacks: Denial of Service (DoS/DDoS) or rate-limiting issues.
34
+ - Low-impact: Clickjacking on non-sensitive pages, or bugs requiring jailbroken devices/unsupported browsers.
35
+ - Third-party: Issues in libraries or services not directly managed by us.
36
+
37
+ ### Responsible Disclosure Guidelines
38
+
39
+ To remain in good standing with our team, we ask that you:
40
+
41
+ - Do not publicly disclose vulnerabilities before we have confirmed a fix.
42
+ - Do not access, modify, or delete data that does not belong to you.
43
+ - Avoid any actions that could degrade or disrupt our services.
44
+
45
+ We appreciate the efforts of the security community in helping us maintain a safe environment.
46
+
47
+ ### Disclaimer
48
+
49
+ This is a voluntary disclosure program rooted in the spirit of open-source collaboration. We operate this program to benefit the broader community and ensure the collective safety of our users. We do not offer any form of compensation for submitted reports. By submitting a report, you acknowledge that you are doing so without expectation of payment and waive any future claims for compensation.
50
+
51
+ ### Related
52
+
53
+ - [Security Advisory](https://altcha.org/security-advisory)
54
+
55
+ ---
56
+
57
+ Updated: Feb 4, 2026
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", "~> 2.5"
26
- spec.add_development_dependency "rake", "~> 10.0"
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