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.
- 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 +14 -12
- data/README.md +191 -75
- 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 -430
- metadata +33 -10
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.
|
|
26
|
-
|
|
27
|
-
spec.add_development_dependency "
|
|
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
|
data/examples/server.rb
ADDED
|
@@ -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
|