altcha 0.2.1 → 2.0.0.beta1

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.
@@ -0,0 +1,211 @@
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
+ options = Altcha::V2::CreateChallengeOptions.new(
84
+ algorithm: 'ARGON2ID',
85
+ cost: 2,
86
+ memory_cost: 65536,
87
+ counter: 10,
88
+ expires_at: Time.now + 300, # 5 minutes
89
+ hmac_signature_secret: HMAC_SECRET,
90
+ hmac_key_signature_secret: HMAC_KEY_SECRET
91
+ )
92
+
93
+ challenge = Altcha::V2.create_challenge(options)
94
+ json_response(res, 200, challenge.to_h)
95
+ end
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # POST /submit
99
+ # ---------------------------------------------------------------------------
100
+
101
+ server.mount_proc '/submit' do |req, res|
102
+ cors_headers(res)
103
+
104
+ if req.request_method == 'OPTIONS'
105
+ res.status = 204
106
+ next
107
+ end
108
+
109
+ unless req.request_method == 'POST'
110
+ json_response(res, 405, { error: 'Method not allowed' })
111
+ next
112
+ end
113
+
114
+ # Parse the request body based on Content-Type.
115
+ content_type = req['Content-Type'].to_s
116
+ form_data = {}
117
+ altcha_value = nil
118
+
119
+ begin
120
+ if content_type.include?('application/json')
121
+ parsed = JSON.parse(req.body || '{}')
122
+ altcha_value = parsed.delete('altcha')
123
+ form_data = parsed
124
+
125
+ elsif content_type.include?('application/x-www-form-urlencoded')
126
+ parsed = URI.decode_www_form(req.body || '').to_h
127
+ altcha_value = parsed.delete('altcha')
128
+ form_data = parsed
129
+
130
+ else
131
+ json_response(res, 415, { error: 'Unsupported content type' })
132
+ next
133
+ end
134
+ rescue JSON::ParserError
135
+ json_response(res, 400, { error: 'Invalid JSON body' })
136
+ next
137
+ end
138
+
139
+ if altcha_value.nil? || altcha_value.empty?
140
+ json_response(res, 400, { error: 'Missing altcha field' })
141
+ next
142
+ end
143
+
144
+ # Decode and verify the ALTCHA payload.
145
+ # Detect type by presence of 'verificationData' (server signature) vs 'solution' (client payload).
146
+ begin
147
+ decoded = JSON.parse(Base64.decode64(altcha_value))
148
+
149
+ result = if decoded.key?('verificationData')
150
+ Altcha::V2.verify_server_signature(
151
+ payload: Altcha::V2::ServerSignaturePayload.from_h(decoded),
152
+ hmac_secret: HMAC_SECRET
153
+ )
154
+ else
155
+ payload = Altcha::V2::Payload.new(
156
+ challenge: Altcha::V2::Challenge.from_h(decoded['challenge']),
157
+ solution: Altcha::V2::Solution.new(
158
+ counter: decoded['solution']['counter'],
159
+ derived_key: decoded['solution']['derivedKey']
160
+ )
161
+ )
162
+ Altcha::V2.verify_solution(
163
+ payload.challenge,
164
+ payload.solution,
165
+ hmac_signature_secret: HMAC_SECRET,
166
+ hmac_key_signature_secret: HMAC_KEY_SECRET
167
+ )
168
+ end
169
+ rescue StandardError => e
170
+ json_response(res, 400, { error: "Invalid altcha payload: #{e.message}" })
171
+ next
172
+ end
173
+
174
+ altcha_result = {
175
+ verified: result.verified,
176
+ expired: result.expired,
177
+ invalid_signature: result.invalid_signature,
178
+ invalid_solution: result.invalid_solution,
179
+ time: result.time
180
+ }
181
+ altcha_result[:verification_data] = result.verification_data if result.respond_to?(:verification_data)
182
+
183
+ unless result.verified
184
+ reason = if result.expired
185
+ 'Challenge expired'
186
+ elsif result.invalid_signature
187
+ 'Invalid challenge signature'
188
+ else
189
+ 'Incorrect solution'
190
+ end
191
+ json_response(res, 400, { error: reason, altcha: altcha_result })
192
+ next
193
+ end
194
+
195
+ # The form data is now trusted. Process it here.
196
+ $stdout.puts "Verified submission: #{form_data}"
197
+
198
+ json_response(res, 200, { success: true, received: form_data, altcha: altcha_result })
199
+ end
200
+
201
+ # ---------------------------------------------------------------------------
202
+ # Start
203
+ # ---------------------------------------------------------------------------
204
+
205
+ trap('INT') { server.shutdown }
206
+ trap('TERM') { server.shutdown }
207
+
208
+ $stdout.puts "Listening on http://localhost:#{PORT}"
209
+ $stdout.puts "HMAC_SECRET: #{HMAC_SECRET == 'change-me-in-production' ? '(default — set HMAC_SECRET env var)' : '(set)'}"
210
+ $stdout.puts "HMAC_KEY_SECRET: #{HMAC_KEY_SECRET == 'change-me-in-production' ? '(default — set HMAC_KEY_SECRET env var)' : '(set)'}"
211
+ 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