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.
- checksums.yaml +4 -4
- data/.github/ISSUE_TEMPLATE/bug_report.md +45 -0
- data/.github/ISSUE_TEMPLATE/config.yml +5 -0
- data/.github/workflows/publish.yml +24 -0
- data/CODE_OF_CONDUCT.md +128 -0
- data/CONTRIBUTING.md +15 -0
- data/Gemfile.lock +11 -9
- data/README.md +190 -74
- data/SECURITY.md +57 -0
- data/altcha.gemspec +6 -3
- data/examples/server.rb +201 -0
- data/lib/altcha/v1.rb +336 -0
- data/lib/altcha/v2.rb +586 -0
- data/lib/altcha/version.rb +1 -1
- data/lib/altcha.rb +2 -433
- metadata +33 -10
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
|
data/lib/altcha/version.rb
CHANGED