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.
- checksums.yaml +4 -4
- data/.github/workflows/publish.yml +24 -0
- data/CODE_OF_CONDUCT.md +128 -0
- data/CONTRIBUTING.md +36 -0
- data/Gemfile.lock +14 -12
- data/README.md +190 -74
- data/SECURITY.md +57 -0
- data/altcha.gemspec +6 -3
- data/examples/server.rb +211 -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 -432
- metadata +31 -10
data/examples/server.rb
ADDED
|
@@ -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
|