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/lib/altcha/v2.rb ADDED
@@ -0,0 +1,586 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require 'base64'
5
+ require 'json'
6
+ require 'time'
7
+ require 'uri'
8
+
9
+ module Altcha
10
+ # V2 proof-of-work: find a counter C such that KDF(nonce+C) starts with keyPrefix.
11
+ # Supports SHA-*, PBKDF2/SHA-*, and SCRYPT algorithms via OpenSSL::KDF.
12
+ module V2
13
+ DEFAULT_KEY_LENGTH = 32
14
+ DEFAULT_KEY_PREFIX = '00'
15
+
16
+ # All parameters embedded in a v2 challenge.
17
+ class ChallengeParameters
18
+ attr_accessor :algorithm, :nonce, :salt, :cost, :key_length, :key_prefix,
19
+ :key_signature, :memory_cost, :parallelism, :expires_at, :data
20
+
21
+ def initialize(algorithm:, nonce:, salt:, cost:, key_length: DEFAULT_KEY_LENGTH,
22
+ key_prefix: DEFAULT_KEY_PREFIX, key_signature: nil,
23
+ memory_cost: nil, parallelism: nil, expires_at: nil, data: nil)
24
+ @algorithm = algorithm
25
+ @nonce = nonce
26
+ @salt = salt
27
+ @cost = cost
28
+ @key_length = key_length
29
+ @key_prefix = key_prefix
30
+ @key_signature = key_signature
31
+ @memory_cost = memory_cost
32
+ @parallelism = parallelism
33
+ @expires_at = expires_at
34
+ @data = data
35
+ end
36
+
37
+ # Serializes to a plain Hash with camelCase keys, omitting nil optional fields.
38
+ # The resulting hash must be stable across round-trips for HMAC signing to work.
39
+ def to_h
40
+ h = {
41
+ 'algorithm' => algorithm,
42
+ 'cost' => cost,
43
+ 'keyLength' => key_length,
44
+ 'keyPrefix' => key_prefix,
45
+ 'nonce' => nonce,
46
+ 'salt' => salt,
47
+ }
48
+ h['data'] = data unless data.nil?
49
+ h['expiresAt'] = expires_at unless expires_at.nil?
50
+ h['keySignature'] = key_signature unless key_signature.nil?
51
+ h['memoryCost'] = memory_cost unless memory_cost.nil?
52
+ h['parallelism'] = parallelism unless parallelism.nil?
53
+ h
54
+ end
55
+
56
+ def to_json(options = {})
57
+ to_h.to_json(options)
58
+ end
59
+ end
60
+
61
+ # A v2 challenge as returned by V2.create_challenge.
62
+ class Challenge
63
+ attr_accessor :parameters, :signature
64
+
65
+ def initialize(parameters:, signature: nil)
66
+ @parameters = parameters
67
+ @signature = signature
68
+ end
69
+
70
+ def to_h
71
+ h = { 'parameters' => parameters.to_h }
72
+ h['signature'] = signature unless signature.nil?
73
+ h
74
+ end
75
+
76
+ def to_json(options = {})
77
+ to_h.to_json(options)
78
+ end
79
+
80
+ def self.from_h(data)
81
+ p = data['parameters']
82
+ new(
83
+ parameters: ChallengeParameters.new(
84
+ algorithm: p['algorithm'],
85
+ nonce: p['nonce'],
86
+ salt: p['salt'],
87
+ cost: p['cost'],
88
+ key_length: p.fetch('keyLength', DEFAULT_KEY_LENGTH),
89
+ key_prefix: p.fetch('keyPrefix', DEFAULT_KEY_PREFIX),
90
+ key_signature: p['keySignature'],
91
+ memory_cost: p['memoryCost'],
92
+ parallelism: p['parallelism'],
93
+ expires_at: p['expiresAt'],
94
+ data: p['data']
95
+ ),
96
+ signature: data['signature']
97
+ )
98
+ end
99
+
100
+ def self.from_json(string)
101
+ from_h(JSON.parse(string))
102
+ end
103
+ end
104
+
105
+ # The solution produced by V2.solve_challenge.
106
+ class Solution
107
+ attr_accessor :counter, :derived_key, :time
108
+
109
+ def initialize(counter:, derived_key:, time: nil)
110
+ @counter = counter
111
+ @derived_key = derived_key
112
+ @time = time
113
+ end
114
+
115
+ def to_h
116
+ { 'counter' => counter, 'derivedKey' => derived_key }
117
+ end
118
+
119
+ def to_json(options = {})
120
+ to_h.to_json(options)
121
+ end
122
+ end
123
+
124
+ # The client payload submitted after solving a v2 challenge.
125
+ class Payload
126
+ attr_accessor :challenge, :solution
127
+
128
+ def initialize(challenge:, solution:)
129
+ @challenge = challenge
130
+ @solution = solution
131
+ end
132
+
133
+ def to_json(options = {})
134
+ { 'challenge' => challenge.to_h, 'solution' => solution.to_h }.to_json(options)
135
+ end
136
+
137
+ def self.from_json(string)
138
+ data = JSON.parse(string)
139
+ new(
140
+ challenge: Challenge.from_h(data['challenge']),
141
+ solution: Solution.new(
142
+ counter: data['solution']['counter'],
143
+ derived_key: data['solution']['derivedKey']
144
+ )
145
+ )
146
+ end
147
+ end
148
+
149
+ # Detailed result returned by V2.verify_solution.
150
+ class VerifySolutionResult
151
+ attr_accessor :expired, :invalid_signature, :invalid_solution, :time, :verified
152
+
153
+ def initialize(expired:, invalid_signature:, invalid_solution:, time:, verified:)
154
+ @expired = expired
155
+ @invalid_signature = invalid_signature
156
+ @invalid_solution = invalid_solution
157
+ @time = time
158
+ @verified = verified
159
+ end
160
+ end
161
+
162
+ # Payload received from the ALTCHA backend for server-side verification.
163
+ class ServerSignaturePayload
164
+ attr_accessor :algorithm, :api_key, :id, :signature, :verification_data, :verified
165
+
166
+ def initialize(algorithm:, verification_data:, signature:, verified:, api_key: nil, id: nil)
167
+ @algorithm = algorithm
168
+ @api_key = api_key
169
+ @id = id
170
+ @signature = signature
171
+ @verification_data = verification_data
172
+ @verified = verified
173
+ end
174
+
175
+ def to_json(options = {})
176
+ h = {
177
+ 'algorithm' => algorithm,
178
+ 'signature' => signature,
179
+ 'verificationData' => verification_data,
180
+ 'verified' => verified,
181
+ }
182
+ h['apiKey'] = api_key unless api_key.nil?
183
+ h['id'] = id unless id.nil?
184
+ h.to_json(options)
185
+ end
186
+
187
+ def self.from_h(data)
188
+ new(
189
+ algorithm: data['algorithm'],
190
+ api_key: data['apiKey'],
191
+ id: data['id'],
192
+ signature: data['signature'],
193
+ verification_data: data['verificationData'],
194
+ verified: data['verified']
195
+ )
196
+ end
197
+
198
+ def self.from_json(string)
199
+ from_h(JSON.parse(string))
200
+ end
201
+
202
+ def self.from_base64(string)
203
+ from_json(Base64.decode64(string))
204
+ end
205
+ end
206
+
207
+ # Detailed result returned by V2.verify_server_signature.
208
+ class VerifyServerSignatureResult
209
+ attr_accessor :expired, :invalid_signature, :invalid_solution, :time,
210
+ :verification_data, :verified
211
+
212
+ def initialize(expired:, invalid_signature:, invalid_solution:, time:,
213
+ verification_data:, verified:)
214
+ @expired = expired
215
+ @invalid_signature = invalid_signature
216
+ @invalid_solution = invalid_solution
217
+ @time = time
218
+ @verification_data = verification_data
219
+ @verified = verified
220
+ end
221
+ end
222
+
223
+ # Options for V2.create_challenge.
224
+ class CreateChallengeOptions
225
+ attr_accessor :algorithm, :cost, :counter, :data, :expires_at,
226
+ :hmac_signature_secret, :hmac_key_signature_secret,
227
+ :key_length, :key_prefix, :key_prefix_length,
228
+ :memory_cost, :parallelism
229
+
230
+ def initialize(algorithm:, cost:, counter: nil, data: nil,
231
+ expires_at: nil, hmac_signature_secret: nil,
232
+ hmac_key_signature_secret: nil, key_length: nil, key_prefix: nil,
233
+ key_prefix_length: nil, memory_cost: nil, parallelism: nil)
234
+ @algorithm = algorithm
235
+ @cost = cost
236
+ @counter = counter
237
+ @data = data
238
+ @expires_at = expires_at
239
+ @hmac_signature_secret = hmac_signature_secret
240
+ @hmac_key_signature_secret = hmac_key_signature_secret
241
+ @key_length = key_length
242
+ @key_prefix = key_prefix
243
+ @key_prefix_length = key_prefix_length
244
+ @memory_cost = memory_cost
245
+ @parallelism = parallelism
246
+ end
247
+ end
248
+
249
+ # -------------------------------------------------------------------------
250
+ # Module-level functions
251
+ # -------------------------------------------------------------------------
252
+
253
+ # Produces a canonical (sorted-key, compact) JSON string.
254
+ def self.canonical_json(obj)
255
+ case obj
256
+ when Hash
257
+ pairs = obj.sort_by { |k, _| k.to_s }
258
+ .map { |k, v| "#{k.to_s.to_json}:#{canonical_json(v)}" }
259
+ "{#{pairs.join(',')}}"
260
+ when Array
261
+ "[#{obj.map { |v| canonical_json(v) }.join(',')}]"
262
+ else
263
+ obj.to_json
264
+ end
265
+ end
266
+
267
+ # Builds the password buffer (nonce bytes + counter) used for key derivation.
268
+ # Counter is encoded as a 4-byte big-endian unsigned integer.
269
+ def self.make_password(nonce_bytes, counter)
270
+ nonce_bytes + [counter].pack('N')
271
+ end
272
+
273
+ # Derives a key from the given parameters, salt, and password bytes.
274
+ def self.derive_key(parameters, salt_bytes, password_bytes)
275
+ alg = parameters.algorithm
276
+ key_len = parameters.key_length || DEFAULT_KEY_LENGTH
277
+
278
+ case alg
279
+ when 'ARGON2ID'
280
+ begin
281
+ require 'argon2/kdf'
282
+ rescue LoadError
283
+ raise LoadError, "Add 'argon2-kdf' to your Gemfile to use the ARGON2ID algorithm"
284
+ end
285
+ # argon2-kdf's `m` is log2(memory_cost_in_KiB) — convert from KiB.
286
+ m_kib = parameters.memory_cost || 65536
287
+ Argon2::KDF.argon2id(
288
+ password_bytes,
289
+ salt: salt_bytes,
290
+ t: parameters.cost,
291
+ m: Math.log2(m_kib).round,
292
+ p: parameters.parallelism || 1,
293
+ length: key_len
294
+ )
295
+ when /\APBKDF2\//
296
+ digest = case alg
297
+ when 'PBKDF2/SHA-512' then 'SHA512'
298
+ when 'PBKDF2/SHA-384' then 'SHA384'
299
+ else 'SHA256'
300
+ end
301
+ OpenSSL::KDF.pbkdf2_hmac(
302
+ password_bytes,
303
+ salt: salt_bytes,
304
+ iterations: parameters.cost,
305
+ length: key_len,
306
+ hash: digest
307
+ )
308
+ when 'SCRYPT'
309
+ OpenSSL::KDF.scrypt(
310
+ password_bytes,
311
+ salt: salt_bytes,
312
+ N: parameters.cost,
313
+ r: parameters.memory_cost || 8,
314
+ p: parameters.parallelism || 1,
315
+ length: key_len
316
+ )
317
+ else
318
+ # SHA-256 / SHA-384 / SHA-512 (iterative)
319
+ digest = case alg
320
+ when 'SHA-512' then 'SHA512'
321
+ when 'SHA-384' then 'SHA384'
322
+ else 'SHA256'
323
+ end
324
+ iterations = [parameters.cost, 1].max
325
+ buf = salt_bytes.b + password_bytes.b
326
+ derived = nil
327
+ iterations.times do |i|
328
+ derived = OpenSSL::Digest.digest(digest, i.zero? ? buf : derived)
329
+ end
330
+ derived[0, key_len]
331
+ end
332
+ end
333
+
334
+ # Computes an HMAC hex digest using the specified algorithm ('SHA-256' etc.).
335
+ def self.hmac_hex(data, key, algorithm = 'SHA-256')
336
+ digest = case algorithm
337
+ when 'SHA-384' then 'SHA384'
338
+ when 'SHA-512' then 'SHA512'
339
+ else 'SHA256'
340
+ end
341
+ OpenSSL::HMAC.hexdigest(digest, key, data)
342
+ end
343
+
344
+ # Constant-time string comparison.
345
+ def self.constant_time_equal?(a, b)
346
+ return false if a.bytesize != b.bytesize
347
+
348
+ OpenSSL.fixed_length_secure_compare(a, b)
349
+ rescue ArgumentError
350
+ false
351
+ end
352
+
353
+ # Creates a v2 proof-of-work challenge.
354
+ # @param options [CreateChallengeOptions]
355
+ # @return [Challenge]
356
+ def self.create_challenge(options)
357
+ key_length = options.key_length || DEFAULT_KEY_LENGTH
358
+ key_prefix = options.key_prefix || DEFAULT_KEY_PREFIX
359
+ key_prefix_length = options.key_prefix_length || (key_length / 2)
360
+ expires_at = options.expires_at.is_a?(Time) ? options.expires_at.to_i : options.expires_at
361
+
362
+ parameters = ChallengeParameters.new(
363
+ algorithm: options.algorithm,
364
+ nonce: OpenSSL::Random.random_bytes(16).unpack1('H*'),
365
+ salt: OpenSSL::Random.random_bytes(16).unpack1('H*'),
366
+ cost: options.cost,
367
+ key_length: key_length,
368
+ key_prefix: key_prefix,
369
+ memory_cost: options.memory_cost,
370
+ parallelism: options.parallelism,
371
+ expires_at: expires_at,
372
+ data: options.data
373
+ )
374
+
375
+ derived_key_bytes = nil
376
+
377
+ if options.counter
378
+ nonce_bytes = [parameters.nonce].pack('H*')
379
+ salt_bytes = [parameters.salt].pack('H*')
380
+ password_bytes = make_password(nonce_bytes, options.counter)
381
+ derived_key_bytes = derive_key(parameters, salt_bytes, password_bytes)
382
+ parameters.key_prefix = derived_key_bytes[0, key_prefix_length].unpack1('H*')
383
+ end
384
+
385
+ if options.hmac_signature_secret
386
+ if derived_key_bytes && options.hmac_key_signature_secret
387
+ parameters.key_signature = hmac_hex(
388
+ derived_key_bytes,
389
+ options.hmac_key_signature_secret
390
+ )
391
+ end
392
+ signature = hmac_hex(
393
+ canonical_json(parameters.to_h),
394
+ options.hmac_signature_secret
395
+ )
396
+ Challenge.new(parameters: parameters, signature: signature)
397
+ else
398
+ Challenge.new(parameters: parameters)
399
+ end
400
+ end
401
+
402
+ # Solves a v2 challenge by brute-forcing counter values.
403
+ # @param challenge [Challenge]
404
+ # @param max_counter [Integer, nil] Safety cap; nil means no limit.
405
+ # @param counter_start [Integer]
406
+ # @param counter_step [Integer]
407
+ # @return [Solution, nil]
408
+ def self.solve_challenge(challenge, max_counter: nil, counter_start: 0, counter_step: 1)
409
+ parameters = challenge.parameters
410
+ nonce_bytes = [parameters.nonce].pack('H*')
411
+ salt_bytes = [parameters.salt].pack('H*')
412
+ key_prefix = parameters.key_prefix
413
+ start_time = Time.now
414
+ counter = counter_start
415
+
416
+ loop do
417
+ return nil if max_counter && counter > max_counter
418
+
419
+ password_bytes = make_password(nonce_bytes, counter)
420
+ derived_key_bytes = derive_key(parameters, salt_bytes, password_bytes)
421
+ derived_key_hex = derived_key_bytes.unpack1('H*')
422
+
423
+ if derived_key_hex.start_with?(key_prefix)
424
+ return Solution.new(
425
+ counter: counter,
426
+ derived_key: derived_key_hex,
427
+ time: ((Time.now - start_time) * 1000).round
428
+ )
429
+ end
430
+
431
+ counter += counter_step
432
+ end
433
+ end
434
+
435
+ # Verifies a v2 solution against its challenge.
436
+ # @param challenge [Challenge]
437
+ # @param solution [Solution]
438
+ # @param hmac_signature_secret [String] Must match what was used in create_challenge.
439
+ # @param hmac_key_signature_secret [String, nil] Required when keySignature is present.
440
+ # @param hmac_algorithm [String] Defaults to 'SHA-256'.
441
+ # @return [VerifySolutionResult]
442
+ def self.verify_solution(challenge, solution, hmac_signature_secret:,
443
+ hmac_key_signature_secret: nil,
444
+ hmac_algorithm: 'SHA-256')
445
+ start_time = Time.now
446
+
447
+ # 1. Expiration check.
448
+ if challenge.parameters.expires_at && Time.now.to_i > challenge.parameters.expires_at
449
+ return VerifySolutionResult.new(
450
+ expired: true, invalid_signature: nil, invalid_solution: nil,
451
+ time: elapsed_ms(start_time), verified: false
452
+ )
453
+ end
454
+
455
+ # 2. Signature presence check.
456
+ unless challenge.signature
457
+ return VerifySolutionResult.new(
458
+ expired: false, invalid_signature: true, invalid_solution: nil,
459
+ time: elapsed_ms(start_time), verified: false
460
+ )
461
+ end
462
+
463
+ # 3. Verify challenge signature (tamper detection).
464
+ expected_sig = hmac_hex(
465
+ canonical_json(challenge.parameters.to_h),
466
+ hmac_signature_secret,
467
+ hmac_algorithm
468
+ )
469
+ unless constant_time_equal?(challenge.signature, expected_sig)
470
+ return VerifySolutionResult.new(
471
+ expired: false, invalid_signature: true, invalid_solution: nil,
472
+ time: elapsed_ms(start_time), verified: false
473
+ )
474
+ end
475
+
476
+ # 4a. Fast path: verify via key signature when available.
477
+ if challenge.parameters.key_signature && hmac_key_signature_secret
478
+ derived_key_bytes = [solution.derived_key].pack('H*')
479
+ expected_key_sig = hmac_hex(derived_key_bytes, hmac_key_signature_secret, hmac_algorithm)
480
+ valid = constant_time_equal?(challenge.parameters.key_signature, expected_key_sig)
481
+ return VerifySolutionResult.new(
482
+ expired: false, invalid_signature: false, invalid_solution: !valid,
483
+ time: elapsed_ms(start_time), verified: valid
484
+ )
485
+ end
486
+
487
+ # 4b. Slow path: re-derive key from the submitted counter and compare.
488
+ nonce_bytes = [challenge.parameters.nonce].pack('H*')
489
+ salt_bytes = [challenge.parameters.salt].pack('H*')
490
+ password_bytes = make_password(nonce_bytes, solution.counter)
491
+ derived_key_bytes = derive_key(challenge.parameters, salt_bytes, password_bytes)
492
+ derived_key_hex = derived_key_bytes.unpack1('H*')
493
+ invalid = !constant_time_equal?(derived_key_hex, solution.derived_key)
494
+
495
+ VerifySolutionResult.new(
496
+ expired: false, invalid_signature: false, invalid_solution: invalid,
497
+ time: elapsed_ms(start_time), verified: !invalid
498
+ )
499
+ end
500
+
501
+ # Parses a URL-encoded verification_data string into a typed Hash.
502
+ # Booleans, integers, and floats are auto-detected; comma-separated fields
503
+ # listed in +array_fields+ are converted to arrays.
504
+ def self.parse_verification_data(data, array_fields: %w[fields reasons])
505
+ result = {}
506
+ URI.decode_www_form(data).each do |key, value|
507
+ result[key] = if value == 'true'
508
+ true
509
+ elsif value == 'false'
510
+ false
511
+ elsif /\A\d+\z/.match?(value)
512
+ value.to_i
513
+ elsif /\A\d+\.\d+\z/.match?(value)
514
+ value.to_f
515
+ elsif array_fields.include?(key) && !value.empty?
516
+ value.strip.split(',')
517
+ else
518
+ value.strip
519
+ end
520
+ end
521
+ result
522
+ rescue StandardError
523
+ nil
524
+ end
525
+
526
+ # Verifies the SHA hash of selected form fields.
527
+ # @param form_data [Hash]
528
+ # @param fields [Array<String>]
529
+ # @param fields_hash [String] Expected hex digest.
530
+ # @param algorithm [String] Defaults to 'SHA-256'.
531
+ # @return [Boolean]
532
+ def self.verify_fields_hash(form_data:, fields:, fields_hash:, algorithm: 'SHA-256')
533
+ digest = case algorithm
534
+ when 'SHA-512' then 'SHA512'
535
+ when 'SHA-384' then 'SHA384'
536
+ else 'SHA256'
537
+ end
538
+ lines = fields.map { |f| form_data[f].to_s }
539
+ OpenSSL::Digest.hexdigest(digest, lines.join("\n")) == fields_hash
540
+ end
541
+
542
+ # Verifies a server signature payload from the ALTCHA backend.
543
+ # @param payload [ServerSignaturePayload]
544
+ # @param hmac_secret [String]
545
+ # @return [VerifyServerSignatureResult]
546
+ def self.verify_server_signature(payload:, hmac_secret:)
547
+ start_time = Time.now
548
+
549
+ digest = case payload.algorithm
550
+ when 'SHA-512' then 'SHA512'
551
+ when 'SHA-384' then 'SHA384'
552
+ else 'SHA256'
553
+ end
554
+
555
+ hash_bytes = OpenSSL::Digest.digest(digest, payload.verification_data)
556
+ expected_sig = hmac_hex(hash_bytes, hmac_secret, payload.algorithm)
557
+ verification_data = parse_verification_data(payload.verification_data)
558
+
559
+ expired = !!(verification_data &&
560
+ verification_data['expire'] &&
561
+ verification_data['expire'] < Time.now.to_i)
562
+
563
+ invalid_signature = !constant_time_equal?(payload.signature.to_s, expected_sig)
564
+
565
+ invalid_solution = verification_data.nil? ||
566
+ verification_data['verified'] != true ||
567
+ payload.verified != true
568
+
569
+ verified = !expired && !invalid_signature && !invalid_solution
570
+
571
+ VerifyServerSignatureResult.new(
572
+ expired: expired,
573
+ invalid_signature: invalid_signature,
574
+ invalid_solution: invalid_solution,
575
+ time: elapsed_ms(start_time),
576
+ verification_data: verification_data,
577
+ verified: verified
578
+ )
579
+ end
580
+
581
+ def self.elapsed_ms(start_time)
582
+ ((Time.now - start_time) * 1000).round
583
+ end
584
+ private_class_method :elapsed_ms
585
+ end
586
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Altcha
4
- VERSION = '1.0.0'
4
+ VERSION = '2.0.0'
5
5
  end