altcha-rails 0.0.6 → 0.1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 166ab2cdd6732e309f96c332d0483bcb16f984697f6f56ffab78357635aaccc3
4
- data.tar.gz: baa9cf000ae61e2a1da2e3571827ff318d046ca3707e0341c6a6c036718b9278
3
+ metadata.gz: ba8a3f7a187f4b142c1b28a24d9d144b1fe317127df1a318363ff4eed8572acb
4
+ data.tar.gz: 9f46b25f55a4aaaaa00df542f1a517b1b7d28ba48518f2651b16400c95c65287
5
5
  SHA512:
6
- metadata.gz: 554a2a258ad6e498034ae21d82788ca50b93543fcdfe086cc346b270c8ebc35a7c44702a30bf3ca0d618ac3b38c0943f04099b47a34c37ad33c8f5a2e1555b11
7
- data.tar.gz: 9acd0d3bedda678efb8480d9d4935cdc1e1d8f97455eeba40e4dbb692e9753a5d9662ce0d05f841f98af2d2288aa6102267e93765d6873df2c12d59d1b4be0f2
6
+ metadata.gz: 30e3e105f9d0189b5f86ad0ee6e14fe52989ddf2d08f32750293d8e63316dc1631dfd7b05e0ca0580fcbcfe40f5594bd5a8479290f384fb643f2227257cc8ec4
7
+ data.tar.gz: 9e59a4d3bb6348fb59999a6bc157f52180aa0895fecc87c4034088d65f0477e6e17db87c373a07a4dd078d8e8f7fa3cd6cc534f976fe889cb50ea1823cf338e0
data/.gitignore CHANGED
@@ -1,2 +1,3 @@
1
1
  /*.gem
2
+ /Gemfile.lock
2
3
 
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source "https://rubygems.org"
2
+
3
+ gemspec
data/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  `altcha-rails` is a Ruby gem that provides a simple way to integrate ALTCHA into your Ruby on Rails application.
8
8
 
9
- The main functionality of the gem is to generate a challenge and verify the response from the client. This is done in the library code. An initializer and a controller is installed in the host application to handle the challenge generation and verification.
9
+ The gem provides two module methods: `Altcha.create_challenge`, which produces a fresh challenge for the form, and `Altcha.verify`, which validates the widget's submission and records it in `Rails.cache` for replay protection.
10
10
 
11
11
  ## Installation
12
12
 
@@ -16,82 +16,68 @@ Add this line to your application's Gemfile:
16
16
  gem 'altcha-rails'
17
17
  ```
18
18
 
19
- Then execute `bundle install` to install the gem for your application.
20
-
21
- Next, run the generator to install the initializer and the controller:
22
-
23
- ```
24
- $ rails generate altcha:install
25
- create app/models/altcha_solution.rb
26
- create app/controllers/altcha_controller.rb
27
- create config/initializers/altcha.rb
28
- route get '/altcha', to: 'altcha#new'
29
- create db/migrate/20240211145410_create_altcha_solutions.rb
30
- ```
31
-
32
- This will create an initializer file at `config/initializers/altcha.rb` and a controller at `app/controllers/altcha_controller.rb` as well as a route in `config/routes.rb` and a model at `app/models/altcha-solutions.rb` (see below).
33
-
34
- You will also have to run 'rails db:migrate` to apply pending changes to the database.
19
+ Then execute `bundle install`.
35
20
 
36
21
  ## Configuration
37
22
 
38
- The initializer file `config/initializers/altcha.rb` contains the following configuration options:
23
+ Create `config/initializers/altcha.rb` with the following configuration options:
39
24
 
40
25
  ```ruby
41
26
  Altcha.setup do |config|
42
- config.algorithm = 'SHA-256'
43
- config.num_range = (50_000..500_000)
44
- config.timeout = 5.minutes
45
- config.hmac_key = 'change-me'
27
+ config.hmac_key = ENV.fetch('ALTCHA_HMAC_KEY')
28
+ config.algorithm = 'SHA-256' # default
29
+ config.max_number = 1_000_000 # difficulty: upper bound for the proof-of-work nonce. default 1_000_000
30
+ config.timeout = 5.minutes # default 300 seconds; accepts integers or ActiveSupport durations
31
+ config.cache_key_prefix = 'altcha:solution:' # default; prepended to the Rails.cache key used for replay protection
46
32
  end
47
33
  ```
48
34
 
49
- The `algorithm` option specifies the hashing algorithm to use and must currently be set to `SHA-256`.
50
- It is crucial change the `hmac_key` to a random value. This key is used to sign the challenge and the response,
51
- so it must be treated as a secret within your application.
52
- The `num_range` option specifies the range of numbers to use in the challenge and determines the difficulty of the proof-of-work.
53
- For an explanation of the `timeout` option see below.
35
+ `hmac_key` has no default it must be set explicitly. The other options have the defaults shown above.
54
36
 
55
37
  ## Challenge expiration
56
38
 
57
- The current time of the server is included in the salt of the challenge. When the client responds, it has to send the
58
- same salt back, so the server can determine when the challenge was issued. The `timeout` option in the initializer file
59
- specifies the time that a challenge is valid. If the response is received after the timeout, the verification will fail.
39
+ Each challenge embeds an `expires` parameter in its salt a Unix timestamp set to `Time.now + Altcha.timeout`.
40
+ When the client responds, the server rejects the submission if that timestamp has passed.
41
+
42
+ The salt is laid out in the canonical v1 ALTCHA format — `<random_hex>?expires=<unix_seconds>&` — including the
43
+ trailing `&` delimiter that closes the parameter list before the proof-of-work nonce is appended for hashing. This
44
+ delimiter is required by the protocol fix for [CVE-2025-68113](https://altcha.org/security-advisory/) and is enforced
45
+ by `Altcha.verify`.
60
46
 
61
47
  As users might complete the captcha before filling out a complex form, the `timeout` should be set to a reasonable
62
48
  value.
63
49
 
64
50
  ## Replay attacks
65
51
 
66
- To also guard against replay attacks within the configured `timeout` period, the gem uses a model named `AltchaSolution` to
67
- store completed responses. A unique constraint is added to the database to prevent the same response from being stored.
68
-
69
- As these stored solutions are useless after the `timeout` period, the `AltchaSolution.cleanup` convenience function
70
- should be called regularly to purge outdates soltutions from the database.
52
+ To also guard against replay attacks within the configured `timeout` period, the gem records each accepted
53
+ solution in `Rails.cache`, keyed by the solution's HMAC signature. Entries are written with `expires_in: Altcha.timeout`
54
+ and `unless_exist: true`, so a replayed submission within the timeout window is rejected atomically, and entries
55
+ expire automatically once the timeout has passed. No periodic cleanup is required.
71
56
 
72
- ## Usage
57
+ Make sure `Rails.cache` is configured to use a backend that is shared across all server processes (e.g.
58
+ `:redis_cache_store`, `:mem_cache_store`, `:solid_cache_store`, or `:file_store`). The default `:memory_store` is
59
+ per-process and would let a replay slip through on a different worker; `:null_store` disables replay protection
60
+ entirely.
73
61
 
74
- You need to include the ALTCHA javascript widget in your application's asset pipeline. This is not done by the gem
75
- at this point. Read up on the [ALTCHA documentation](https://altcha.org/docs/website-integration) for more information.
62
+ ## Issuing a challenge
76
63
 
77
- Then add the following code to the form you want to protect:
64
+ `Altcha.create_challenge` returns an `Altcha::Challenge` whose `#to_json` produces exactly the payload the widget expects. The widget accepts this JSON directly via its `challenge` attribute, so no separate `/altcha` route is needed:
78
65
 
79
66
  ```erb
80
- <altcha-widget challengeurl="<%= altcha_url() %>"></altcha-widget>
67
+ <altcha-widget challenge='<%= Altcha.create_challenge.to_json %>'></altcha-widget>
81
68
  ```
82
69
 
83
- Once the user clicks the checkbox, the widget will send a request to the server to get a new challenge.
84
- When the user-side code inside the widget found the solution to the challenge, the spinner will stop
85
- and a hidden input field with the name `altcha` will be created in the form to convey the solution as
86
- base64 encoded JSON dictionary.
70
+ Include the ALTCHA javascript widget script in your asset pipeline; see [the ALTCHA documentation](https://altcha.org/docs/website-integration) for the widget itself.
87
71
 
88
- In the controller that handles the form submission, you can verify the response with the following code:
72
+ ## Verifying a submission
73
+
74
+ When the form is submitted, the widget sends a base64-encoded JSON payload in a hidden input named `altcha`. In the controller that handles the submission, verify it with:
89
75
 
90
76
  ```ruby
91
77
  def create
92
78
  @model = Model.create(model_params)
93
79
 
94
- unless AltchaSolution.verify_and_save(params.permit(:altcha)[:altcha])
80
+ unless Altcha.verify(params.permit(:altcha)[:altcha])
95
81
  flash.now[:alert] = 'ALTCHA verification failed.'
96
82
  render :new, status: :unprocessable_entity
97
83
  return
@@ -101,7 +87,145 @@ def create
101
87
  end
102
88
  ```
103
89
 
104
- The `verify_and_save` method will return `true` if the response is valid and has not been used before.
90
+ `Altcha.verify` returns the `Altcha::Submission` if the response is valid and has not been seen before within the
91
+ timeout window, and `nil` otherwise.
92
+
93
+ ## Development
94
+
95
+ ```
96
+ bundle install
97
+ bundle exec rake test
98
+ ```
99
+
100
+ Tests use Minitest and a small `FakeCache` shim that mimics the bits of `Rails.cache` the gem touches, so the suite runs without booting Rails.
101
+
102
+ ## Changelog
103
+
104
+ ### 0.1.0
105
+
106
+ This release is a substantial rework. The public API collapses to two module methods, and everything else (model, migration, controller, route, generator) is gone.
107
+
108
+ **Highlights:**
109
+
110
+ - **Security fix — [CVE-2025-68113](https://altcha.org/security-advisory/) (challenge splicing / replay).** Salt now follows the canonical v1 ALTCHA format `<random_hex>?expires=<unix_seconds>&`, and `Altcha.verify` normalises the salt to its trailing-`&` form before recomputing the proof-of-work hash. A spliced salt no longer round-trips to the same digest.
111
+ - **No more `AltchaSolution` ActiveRecord model or `altcha_solutions` table.** Replay protection lives in `Rails.cache` (keyed by the submission's HMAC signature, TTL = `Altcha.timeout`, atomic via `unless_exist: true`).
112
+ - **No more generated controller or route.** The widget now accepts the challenge JSON inline via its `challenge` attribute, so the host application no longer needs an endpoint to serve challenges.
113
+ - **No more `rails generate altcha:install` generator.** Configuration is one `Altcha.setup` block — see [Configuration](#configuration) above.
114
+ - **Configuration knob renamed**: `num_range` (Range) → `max_number` (Integer). `hmac_key` no longer has a placeholder default and must be set explicitly. A new `cache_key_prefix` option (default `"altcha:solution:"`) lets you namespace the replay-tracking keys.
115
+
116
+ #### Upgrade guide from 0.0.x
117
+
118
+ **1. Confirm your cache backend.** It must be shared across all server processes. In production: `:redis_cache_store`, `:mem_cache_store`, `:solid_cache_store`, `:file_store`, or another shared backend. The default `:memory_store` is per-process and is unsafe here; `:null_store` disables replay protection entirely. See the [Challenge expiration](#challenge-expiration) section.
119
+
120
+ **2. Delete the generated model:**
121
+
122
+ ```
123
+ rm app/models/altcha_solution.rb
124
+ ```
125
+
126
+ If you have specs covering it, remove those too.
127
+
128
+ **3. Delete the generated controller:**
129
+
130
+ ```
131
+ rm app/controllers/altcha_controller.rb
132
+ ```
133
+
134
+ If you have specs or request tests covering it, remove those too.
135
+
136
+ **4. Remove the route.** In `config/routes.rb`, delete the line:
137
+
138
+ ```ruby
139
+ get '/altcha', to: 'altcha#new'
140
+ ```
141
+
142
+ **5. Update your view to inline the challenge JSON.** Replace:
143
+
144
+ ```erb
145
+ <altcha-widget challengeurl="<%= altcha_url %>"></altcha-widget>
146
+ ```
147
+
148
+ with:
149
+
150
+ ```erb
151
+ <altcha-widget challenge='<%= Altcha.create_challenge.to_json %>'></altcha-widget>
152
+ ```
153
+
154
+ The `challenge` attribute is part of the modern ALTCHA widget; the `challengeurl` round-trip is no longer required.
155
+
156
+ **6. Generate a migration to drop the `altcha_solutions` table:**
157
+
158
+ ```
159
+ $ rails generate migration DropAltchaSolutions
160
+ ```
161
+
162
+ Fill in the generated file as follows (the `down` block is provided so the migration is reversible — adjust the column list if your installation customised it):
163
+
164
+ ```ruby
165
+ class DropAltchaSolutions < ActiveRecord::Migration[7.1]
166
+ def up
167
+ drop_table :altcha_solutions
168
+ end
169
+
170
+ def down
171
+ create_table :altcha_solutions do |t|
172
+ t.string :algorithm
173
+ t.string :challenge
174
+ t.string :salt
175
+ t.string :signature
176
+ t.integer :number
177
+
178
+ t.timestamps
179
+ end
180
+
181
+ add_index :altcha_solutions,
182
+ [:algorithm, :challenge, :salt, :signature, :number],
183
+ unique: true,
184
+ name: 'index_altcha_solutions'
185
+ end
186
+ end
187
+ ```
188
+
189
+ Then run `rails db:migrate`.
190
+
191
+ **7. Replace the verification call.** The public API moves from the generated model to the gem itself. The return value flips from boolean to `Altcha::Submission`-or-`nil`, but the truthy/falsy semantics are unchanged:
192
+
193
+ ```ruby
194
+ # Before (0.0.x):
195
+ unless AltchaSolution.verify_and_save(params.permit(:altcha)[:altcha])
196
+ flash.now[:alert] = 'ALTCHA verification failed.'
197
+ render :new, status: :unprocessable_entity
198
+ return
199
+ end
200
+
201
+ # After (0.1.0):
202
+ unless Altcha.verify(params.permit(:altcha)[:altcha])
203
+ flash.now[:alert] = 'ALTCHA verification failed.'
204
+ render :new, status: :unprocessable_entity
205
+ return
206
+ end
207
+ ```
208
+
209
+ **8. Remove `AltchaSolution.cleanup` calls.** Cache entries now expire automatically via `expires_in: Altcha.timeout`, so any scheduled job, rake task, or cron entry that called `AltchaSolution.cleanup` can be deleted.
210
+
211
+ **9. Update your initializer.** Rename `num_range` to `max_number` (and switch from a Range to an Integer) and remove any placeholder `hmac_key = 'change-me'`:
212
+
213
+ ```ruby
214
+ # Before (0.0.x):
215
+ Altcha.setup do |config|
216
+ config.algorithm = 'SHA-256'
217
+ config.num_range = (50_000..500_000)
218
+ config.timeout = 5.minutes
219
+ config.hmac_key = 'change-me'
220
+ end
221
+
222
+ # After (0.1.0):
223
+ Altcha.setup do |config|
224
+ config.hmac_key = ENV.fetch('ALTCHA_HMAC_KEY')
225
+ config.max_number = 500_000
226
+ config.timeout = 5.minutes
227
+ end
228
+ ```
105
229
 
106
230
  ## Contributing
107
231
 
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ require "rake/testtask"
2
+
3
+ Rake::TestTask.new(:test) do |t|
4
+ t.libs << "test"
5
+ t.libs << "lib"
6
+ t.test_files = FileList["test/**/*_test.rb"]
7
+ t.warning = false
8
+ end
9
+
10
+ task default: :test
data/altcha-rails.gemspec CHANGED
@@ -1,16 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  Gem::Specification.new do |s|
4
- s.name = "altcha-rails"
5
- s.version = "0.0.6"
6
- s.authors = ["Daniel Mack"]
7
- s.homepage = "https://github.com/zonque/altcha-rails"
8
- s.metadata = { "source_code_uri" => "https://github.com/zonque/altcha-rails" }
9
- s.summary = "Rails helpers for ALTCHA"
10
- s.description = "ALTCHA is a free, open-source CAPTCHA alternative that protects your website from spam and abuse"
11
- s.email = "altcha-rails.gem@zonque.org"
4
+ s.name = "altcha-rails"
5
+ s.version = "0.1.0"
6
+ s.authors = ["Daniel Mack"]
7
+ s.email = "altcha-rails.gem@zonque.org"
8
+ s.homepage = "https://github.com/zonque/altcha-rails"
9
+ s.metadata = { "source_code_uri" => "https://github.com/zonque/altcha-rails" }
10
+ s.summary = "Ruby library for ALTCHA"
11
+ s.description = "ALTCHA is a free, open-source CAPTCHA alternative that protects your website from spam and abuse. This gem implements the ALTCHA v1 challenge protocol (challenge creation and submission verification) and integrates with Rails.cache for replay protection."
12
+ s.licenses = ["MIT"]
13
+
14
+ s.required_ruby_version = ">= 3.0"
15
+
12
16
  s.require_paths = ["lib"]
13
17
  s.files = `git ls-files`.split("\n")
14
- s.licenses = ["MIT"]
18
+
19
+ # base64 is no longer a default gem as of Ruby 3.4.
20
+ s.add_runtime_dependency "base64", "~> 0.2"
21
+
22
+ s.add_development_dependency "minitest", "~> 5.0"
23
+ s.add_development_dependency "rake", "~> 13.0"
24
+
15
25
  s.specification_version = 4
16
26
  end
data/lib/altcha-rails.rb CHANGED
@@ -1,65 +1,156 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Altcha
4
- mattr_accessor :configured
5
- @@configured = false
6
-
7
- mattr_accessor :algorithm
8
- @@algorithm = 'SHA-256'
3
+ require "base64"
4
+ require "digest"
5
+ require "json"
6
+ require "openssl"
7
+ require "securerandom"
9
8
 
10
- mattr_accessor :num_range
11
- @@num_range = (50_000..500_000)
9
+ module Altcha
10
+ class ConfigurationError < StandardError; end
12
11
 
13
- mattr_accessor :hmac_key
14
- @@hmac_key = "change-me"
12
+ class << self
13
+ attr_accessor :algorithm, :max_number, :hmac_key, :timeout, :cache_key_prefix
14
+ end
15
15
 
16
- mattr_accessor :timeout
17
- @@timeout = 5.minutes
16
+ self.algorithm = "SHA-256"
17
+ self.max_number = 1_000_000
18
+ self.hmac_key = nil
19
+ self.timeout = 300 # seconds; accepts anything responding to #to_i
20
+ self.cache_key_prefix = "altcha:solution:"
18
21
 
19
22
  def self.setup
20
- @@configured = true
21
23
  yield self
22
24
  end
23
25
 
24
- class Challenge
25
- attr_accessor :algorithm, :challenge, :salt, :signature
26
+ # Returns an Altcha::Challenge. Its #to_json produces the payload the
27
+ # widget expects via the `challenge` attribute.
28
+ def self.create_challenge(**options)
29
+ Challenge.create(**options)
30
+ end
26
31
 
27
- def self.create
28
- raise "Altcha not configured" unless Altcha.configured
32
+ # Verifies a base64-encoded JSON submission AND records it in Rails.cache
33
+ # for replay protection (atomic via `unless_exist: true`, TTL = timeout).
34
+ # Returns the Altcha::Submission on a fresh accept, nil on failure (invalid
35
+ # crypto, expired, spliced, or replay within the timeout window).
36
+ def self.verify(base64_string)
37
+ submission = Submission.verify(base64_string)
38
+ return nil unless submission
39
+
40
+ if Rails.cache.write("#{cache_key_prefix}#{submission.signature}", true,
41
+ expires_in: timeout, unless_exist: true)
42
+ submission
43
+ else
44
+ nil # replay
45
+ end
46
+ end
29
47
 
30
- secret_number = rand(Altcha.num_range)
48
+ class Challenge
49
+ attr_accessor :algorithm, :challenge, :salt, :signature, :max_number
50
+
51
+ def self.create(algorithm: nil, hmac_key: nil, max_number: nil, expires: nil, number: nil)
52
+ hmac_key ||= Altcha.hmac_key
53
+ raise ConfigurationError, "Altcha.hmac_key is not set" if hmac_key.nil? || hmac_key.empty?
54
+
55
+ algorithm ||= Altcha.algorithm
56
+ max_number ||= Altcha.max_number
57
+ expires ||= Time.now.to_i + Altcha.timeout.to_i
58
+ number ||= SecureRandom.random_number(max_number)
59
+
60
+ ch = new
61
+ ch.algorithm = algorithm
62
+ ch.max_number = max_number
63
+ # Canonical v1 ALTCHA salt: random hex, expires parameter, trailing '&'
64
+ # to delimit the parameter list from the nonce (CVE-2025-68113).
65
+ ch.salt = "#{SecureRandom.hex(12)}?expires=#{expires.to_i}&"
66
+ ch.challenge = Digest::SHA256.hexdigest(ch.salt + number.to_s)
67
+ ch.signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new(algorithm), hmac_key, ch.challenge)
68
+ ch
69
+ end
31
70
 
32
- a = Challenge.new
33
- a.algorithm = Altcha.algorithm
34
- a.salt = [Time.now.to_s, SecureRandom.hex(12)].join('|')
35
- a.challenge = Digest::SHA256.hexdigest(a.salt + secret_number.to_s)
36
- a.signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new(a.algorithm), Altcha.hmac_key, a.challenge)
71
+ def to_h
72
+ {
73
+ algorithm: algorithm,
74
+ challenge: challenge,
75
+ maxnumber: max_number,
76
+ salt: salt,
77
+ signature: signature,
78
+ }
79
+ end
37
80
 
38
- return a
81
+ def to_json(*args)
82
+ to_h.to_json(*args)
39
83
  end
40
84
  end
41
85
 
42
86
  class Submission
43
- attr_accessor :algorithm, :challenge, :salt, :signature, :number
44
-
45
- def initialize(v = {})
46
- @algorithm = v["algorithm"] || ""
47
- @challenge = v["challenge"] || ""
48
- @signature = v["signature"] || ""
49
- @salt = v["salt"] || ""
50
- @number = v["number"] || 0
87
+ attr_reader :algorithm, :challenge, :salt, :signature, :number
88
+
89
+ def self.verify(base64_string)
90
+ raw = begin
91
+ Base64.decode64(base64_string.to_s)
92
+ rescue ArgumentError
93
+ return nil
94
+ end
95
+ payload = JSON.parse(raw) rescue nil
96
+ return nil unless payload.is_a?(Hash)
97
+
98
+ submission = new(payload)
99
+ submission.valid? ? submission : nil
100
+ end
101
+
102
+ def initialize(payload = {})
103
+ @algorithm = payload["algorithm"].to_s
104
+ @challenge = payload["challenge"].to_s
105
+ @signature = payload["signature"].to_s
106
+ @salt = payload["salt"].to_s
107
+ @number = payload["number"]
51
108
  end
52
109
 
53
110
  def valid?
54
- check = Digest::SHA256.hexdigest(@salt + @number.to_s)
111
+ return false unless @algorithm == Altcha.algorithm
112
+ return false unless @number.is_a?(Integer)
113
+ return false if Altcha.hmac_key.nil? || Altcha.hmac_key.empty?
114
+
115
+ expires = extract_expires(@salt)
116
+ return false if expires.nil?
117
+ return false unless Time.at(expires) > Time.now
118
+
119
+ # Normalize to canonical trailing-'&' form before recomputing the hash;
120
+ # a spliced salt no longer round-trips to the same digest. Mitigates
121
+ # CVE-2025-68113.
122
+ canonical_salt = @salt.end_with?("&") ? @salt : "#{@salt}&"
123
+ check = Digest::SHA256.hexdigest(canonical_salt + @number.to_s)
124
+
125
+ return false unless @challenge == check
126
+
127
+ expected_sig = OpenSSL::HMAC.hexdigest(
128
+ OpenSSL::Digest.new(@algorithm), Altcha.hmac_key, check
129
+ )
130
+ secure_compare(@signature, expected_sig)
131
+ end
132
+
133
+ private
134
+
135
+ def extract_expires(salt)
136
+ query = salt.split("?", 2)[1]
137
+ return nil unless query
138
+
139
+ query.split("&").each do |pair|
140
+ key, value = pair.split("=", 2)
141
+ return Integer(value, 10) if key == "expires" && value
142
+ end
143
+ nil
144
+ rescue ArgumentError
145
+ nil
146
+ end
55
147
 
56
- parts = @salt.split('|')
57
- t = Time.parse(parts[0]) rescue nil
148
+ def secure_compare(a, b)
149
+ return false unless a.bytesize == b.bytesize
58
150
 
59
- return @algorithm == Altcha.algorithm &&
60
- @challenge == check &&
61
- @signature == OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new(Altcha.algorithm), Altcha.hmac_key, check) &&
62
- t.present? && t > Time.now - Altcha.timeout && t < Time.now
151
+ diff = 0
152
+ a.bytes.zip(b.bytes) { |x, y| diff |= x ^ y }
153
+ diff.zero?
63
154
  end
64
155
  end
65
156
  end
@@ -0,0 +1,278 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class AltchaTest < Minitest::Test
6
+ HMAC_KEY = "test-secret-key"
7
+
8
+ def setup
9
+ @prev_hmac_key = Altcha.hmac_key
10
+ @prev_timeout = Altcha.timeout
11
+ @prev_max_number = Altcha.max_number
12
+ @prev_algorithm = Altcha.algorithm
13
+ @prev_prefix = Altcha.cache_key_prefix
14
+
15
+ Altcha.hmac_key = HMAC_KEY
16
+ Altcha.timeout = 300
17
+ Altcha.max_number = 1_000
18
+ Altcha.algorithm = "SHA-256"
19
+ Altcha.cache_key_prefix = "altcha:solution:"
20
+
21
+ Rails.cache.clear
22
+ end
23
+
24
+ def teardown
25
+ Altcha.hmac_key = @prev_hmac_key
26
+ Altcha.timeout = @prev_timeout
27
+ Altcha.max_number = @prev_max_number
28
+ Altcha.algorithm = @prev_algorithm
29
+ Altcha.cache_key_prefix = @prev_prefix
30
+ end
31
+
32
+ # -- helpers --------------------------------------------------------------
33
+
34
+ def encode(hash)
35
+ Base64.strict_encode64(hash.to_json)
36
+ end
37
+
38
+ def solve(challenge)
39
+ (0..Altcha.max_number).find do |n|
40
+ Digest::SHA256.hexdigest(challenge.salt + n.to_s) == challenge.challenge
41
+ end
42
+ end
43
+
44
+ def payload_for(challenge, number)
45
+ {
46
+ "algorithm" => challenge.algorithm,
47
+ "challenge" => challenge.challenge,
48
+ "number" => number,
49
+ "salt" => challenge.salt,
50
+ "signature" => challenge.signature,
51
+ }
52
+ end
53
+
54
+ def solved_payload
55
+ ch = Altcha.create_challenge
56
+ [ch, encode(payload_for(ch, solve(ch)))]
57
+ end
58
+
59
+ # -- Altcha.create_challenge ---------------------------------------------
60
+
61
+ def test_create_challenge_returns_challenge_with_required_fields
62
+ ch = Altcha.create_challenge
63
+ assert_equal "SHA-256", ch.algorithm
64
+ assert_equal 1_000, ch.max_number
65
+ refute_nil ch.salt
66
+ refute_nil ch.challenge
67
+ refute_nil ch.signature
68
+ end
69
+
70
+ def test_create_challenge_salt_uses_canonical_v1_format
71
+ ch = Altcha.create_challenge
72
+ assert_match(/\A[0-9a-f]{24}\?expires=\d+&\z/, ch.salt,
73
+ "salt must be `<hex>?expires=<unix>&` (CVE-2025-68113 mitigation)")
74
+ end
75
+
76
+ def test_create_challenge_signature_is_hmac_of_challenge_with_configured_key
77
+ ch = Altcha.create_challenge
78
+ expected = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("SHA-256"), HMAC_KEY, ch.challenge)
79
+ assert_equal expected, ch.signature
80
+ end
81
+
82
+ def test_create_challenge_challenge_field_is_sha256_of_salt_plus_secret
83
+ ch = Altcha.create_challenge
84
+ n = solve(ch)
85
+ refute_nil n, "solver could not find secret within max_number"
86
+ assert_equal ch.challenge, Digest::SHA256.hexdigest(ch.salt + n.to_s)
87
+ end
88
+
89
+ def test_create_challenge_to_json_emits_widget_compatible_payload
90
+ json = JSON.parse(Altcha.create_challenge.to_json)
91
+ assert_equal %w[algorithm challenge maxnumber salt signature], json.keys.sort
92
+ assert_equal "SHA-256", json["algorithm"]
93
+ assert_equal 1_000, json["maxnumber"]
94
+ end
95
+
96
+ def test_create_challenge_raises_when_hmac_key_is_nil
97
+ Altcha.hmac_key = nil
98
+ assert_raises(Altcha::ConfigurationError) { Altcha.create_challenge }
99
+ end
100
+
101
+ def test_create_challenge_raises_when_hmac_key_is_empty
102
+ Altcha.hmac_key = ""
103
+ assert_raises(Altcha::ConfigurationError) { Altcha.create_challenge }
104
+ end
105
+
106
+ def test_create_challenge_accepts_per_call_overrides
107
+ explicit_expires = Time.now.to_i + 9999
108
+ ch = Altcha.create_challenge(expires: explicit_expires, number: 42, max_number: 100)
109
+ assert_match(/\?expires=#{explicit_expires}&\z/, ch.salt)
110
+ assert_equal 100, ch.max_number
111
+ assert_equal ch.challenge, Digest::SHA256.hexdigest(ch.salt + "42")
112
+ end
113
+
114
+ # -- Altcha.verify: happy path -------------------------------------------
115
+
116
+ def test_verify_accepts_a_valid_fresh_submission
117
+ ch, b64 = solved_payload
118
+ submission = Altcha.verify(b64)
119
+ refute_nil submission
120
+ assert_kind_of Altcha::Submission, submission
121
+ assert_equal ch.signature, submission.signature
122
+ end
123
+
124
+ # -- Altcha.verify: replay protection ------------------------------------
125
+
126
+ def test_verify_rejects_a_replay_of_the_same_submission
127
+ _ch, b64 = solved_payload
128
+ refute_nil Altcha.verify(b64), "first submission must be accepted"
129
+ assert_nil Altcha.verify(b64), "replay must be rejected"
130
+ end
131
+
132
+ def test_verify_writes_to_cache_under_the_signature_keyed_prefix
133
+ ch, b64 = solved_payload
134
+ Altcha.verify(b64)
135
+ assert_equal true, Rails.cache.read("altcha:solution:#{ch.signature}")
136
+ end
137
+
138
+ def test_verify_honours_configured_cache_key_prefix
139
+ Altcha.cache_key_prefix = "myapp:altcha:"
140
+ ch, b64 = solved_payload
141
+ Altcha.verify(b64)
142
+ assert_equal true, Rails.cache.read("myapp:altcha:#{ch.signature}")
143
+ assert_nil Rails.cache.read("altcha:solution:#{ch.signature}")
144
+ end
145
+
146
+ def test_verify_passes_timeout_through_as_expires_in
147
+ Altcha.timeout = 42
148
+ ch, b64 = solved_payload
149
+ Altcha.verify(b64)
150
+ entry = Rails.cache.entry("altcha:solution:#{ch.signature}")
151
+ assert_equal 42, entry[:expires_in]
152
+ end
153
+
154
+ def test_verify_does_not_touch_cache_when_crypto_check_fails
155
+ bad = "not-a-real-payload"
156
+ Altcha.verify(bad)
157
+ assert_empty Rails.cache.keys
158
+ end
159
+
160
+ # -- Altcha.verify: failure modes (crypto) -------------------------------
161
+
162
+ def test_verify_returns_nil_on_garbage_input
163
+ assert_nil Altcha.verify("garbage!!!!")
164
+ assert_nil Altcha.verify("")
165
+ assert_nil Altcha.verify(nil)
166
+ end
167
+
168
+ def test_verify_returns_nil_when_payload_is_not_a_hash
169
+ assert_nil Altcha.verify(Base64.strict_encode64('"a string"'))
170
+ assert_nil Altcha.verify(Base64.strict_encode64("123"))
171
+ assert_nil Altcha.verify(Base64.strict_encode64("[]"))
172
+ end
173
+
174
+ def test_verify_rejects_wrong_algorithm
175
+ ch = Altcha.create_challenge
176
+ n = solve(ch)
177
+ p = payload_for(ch, n).merge("algorithm" => "SHA-512")
178
+ assert_nil Altcha.verify(encode(p))
179
+ end
180
+
181
+ def test_verify_rejects_non_integer_number
182
+ ch = Altcha.create_challenge
183
+ n = solve(ch)
184
+ p = payload_for(ch, n).merge("number" => n.to_s)
185
+ assert_nil Altcha.verify(encode(p))
186
+ end
187
+
188
+ def test_verify_rejects_tampered_number
189
+ ch = Altcha.create_challenge
190
+ n = solve(ch)
191
+ p = payload_for(ch, n + 1)
192
+ assert_nil Altcha.verify(encode(p))
193
+ end
194
+
195
+ def test_verify_rejects_tampered_challenge
196
+ ch = Altcha.create_challenge
197
+ n = solve(ch)
198
+ p = payload_for(ch, n).merge("challenge" => "0" * ch.challenge.length)
199
+ assert_nil Altcha.verify(encode(p))
200
+ end
201
+
202
+ def test_verify_rejects_tampered_signature
203
+ ch = Altcha.create_challenge
204
+ n = solve(ch)
205
+ p = payload_for(ch, n).merge("signature" => "0" * ch.signature.length)
206
+ assert_nil Altcha.verify(encode(p))
207
+ end
208
+
209
+ def test_verify_rejects_expired_challenge
210
+ Altcha.timeout = -1
211
+ ch = Altcha.create_challenge
212
+ Altcha.timeout = 300
213
+ n = solve(ch)
214
+ assert_nil Altcha.verify(encode(payload_for(ch, n)))
215
+ end
216
+
217
+ def test_verify_rejects_salt_without_expires_parameter
218
+ salt = "#{SecureRandom.hex(12)}&"
219
+ challenge = Digest::SHA256.hexdigest(salt + "42")
220
+ signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("SHA-256"), HMAC_KEY, challenge)
221
+ p = {
222
+ "algorithm" => "SHA-256",
223
+ "challenge" => challenge,
224
+ "number" => 42,
225
+ "salt" => salt,
226
+ "signature" => signature,
227
+ }
228
+ assert_nil Altcha.verify(encode(p))
229
+ end
230
+
231
+ def test_verify_rejects_when_hmac_key_not_configured
232
+ _ch, b64 = solved_payload
233
+ Altcha.hmac_key = nil
234
+ assert_nil Altcha.verify(b64)
235
+ end
236
+
237
+ # -- CVE-2025-68113 regression -------------------------------------------
238
+
239
+ def test_verify_rejects_parameter_splice_attack
240
+ # Construct a hash-colliding splice. Without the trailing-'&' normalisation
241
+ # in valid?, this submission would be accepted:
242
+ # SHA256(salt + "1" + "23") == SHA256(salt + "123")
243
+ salt = "abc123def456abc123def456?expires=#{Time.now.to_i + 60}&"
244
+ legitimate_hash = Digest::SHA256.hexdigest(salt + "123")
245
+ signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("SHA-256"), HMAC_KEY, legitimate_hash)
246
+
247
+ # Sanity: prove the collision exists before testing rejection.
248
+ assert_equal legitimate_hash, Digest::SHA256.hexdigest((salt + "1") + "23"),
249
+ "splice precondition: hash collision must exist for this test to be meaningful"
250
+
251
+ spliced = {
252
+ "algorithm" => "SHA-256",
253
+ "challenge" => legitimate_hash,
254
+ "number" => 23,
255
+ "salt" => salt + "1",
256
+ "signature" => signature,
257
+ }
258
+ assert_nil Altcha.verify(encode(spliced)),
259
+ "CVE-2025-68113: spliced submission must be rejected"
260
+ end
261
+
262
+ # -- Altcha.setup --------------------------------------------------------
263
+
264
+ def test_setup_yields_the_module
265
+ yielded = nil
266
+ Altcha.setup { |c| yielded = c }
267
+ assert_same Altcha, yielded
268
+ end
269
+
270
+ def test_setup_assignments_persist
271
+ Altcha.setup do |c|
272
+ c.max_number = 7777
273
+ c.timeout = 60
274
+ end
275
+ assert_equal 7777, Altcha.max_number
276
+ assert_equal 60, Altcha.timeout
277
+ end
278
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
4
+
5
+ # Stub Rails.cache so the gem's replay-tracking path can run without booting
6
+ # Rails. The fake mimics the subset of ActiveSupport::Cache::Store that the
7
+ # gem uses: write(key, value, expires_in:, unless_exist:).
8
+ class FakeCache
9
+ def initialize
10
+ @store = {}
11
+ end
12
+
13
+ def write(key, value, expires_in:, unless_exist: false)
14
+ return false if unless_exist && @store.key?(key)
15
+
16
+ @store[key] = { value: value, expires_in: expires_in }
17
+ true
18
+ end
19
+
20
+ def read(key)
21
+ entry = @store[key]
22
+ entry && entry[:value]
23
+ end
24
+
25
+ def clear
26
+ @store.clear
27
+ end
28
+
29
+ def keys
30
+ @store.keys
31
+ end
32
+
33
+ def entry(key)
34
+ @store[key]
35
+ end
36
+ end
37
+
38
+ module Rails
39
+ class << self
40
+ attr_accessor :cache
41
+ end
42
+ self.cache = FakeCache.new
43
+ end
44
+
45
+ require "altcha-rails"
46
+ require "minitest/autorun"
metadata CHANGED
@@ -1,17 +1,60 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: altcha-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.6
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel Mack
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2024-04-28 00:00:00.000000000 Z
12
- dependencies: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: base64
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.2'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.2'
26
+ - !ruby/object:Gem::Dependency
27
+ name: minitest
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '5.0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '5.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rake
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '13.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '13.0'
13
54
  description: ALTCHA is a free, open-source CAPTCHA alternative that protects your
14
- website from spam and abuse
55
+ website from spam and abuse. This gem implements the ALTCHA v1 challenge protocol
56
+ (challenge creation and submission verification) and integrates with Rails.cache
57
+ for replay protection.
15
58
  email: altcha-rails.gem@zonque.org
16
59
  executables: []
17
60
  extensions: []
@@ -19,21 +62,19 @@ extra_rdoc_files: []
19
62
  files:
20
63
  - ".editorconfig"
21
64
  - ".gitignore"
65
+ - Gemfile
22
66
  - LICENSE
23
67
  - README.md
68
+ - Rakefile
24
69
  - altcha-rails.gemspec
25
70
  - lib/altcha-rails.rb
26
- - lib/generators/altcha/install/install_generator.rb
27
- - lib/generators/altcha/install/templates/controllers/altcha_controller.rb
28
- - lib/generators/altcha/install/templates/initializers/altcha.rb
29
- - lib/generators/altcha/install/templates/migrations/create_altcha_solutions.rb.erb
30
- - lib/generators/altcha/install/templates/models/altcha_solution.rb
71
+ - test/altcha_test.rb
72
+ - test/test_helper.rb
31
73
  homepage: https://github.com/zonque/altcha-rails
32
74
  licenses:
33
75
  - MIT
34
76
  metadata:
35
77
  source_code_uri: https://github.com/zonque/altcha-rails
36
- post_install_message:
37
78
  rdoc_options: []
38
79
  require_paths:
39
80
  - lib
@@ -41,15 +82,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
41
82
  requirements:
42
83
  - - ">="
43
84
  - !ruby/object:Gem::Version
44
- version: '0'
85
+ version: '3.0'
45
86
  required_rubygems_version: !ruby/object:Gem::Requirement
46
87
  requirements:
47
88
  - - ">="
48
89
  - !ruby/object:Gem::Version
49
90
  version: '0'
50
91
  requirements: []
51
- rubygems_version: 3.4.10
52
- signing_key:
92
+ rubygems_version: 4.0.6
53
93
  specification_version: 4
54
- summary: Rails helpers for ALTCHA
94
+ summary: Ruby library for ALTCHA
55
95
  test_files: []
@@ -1,33 +0,0 @@
1
- # frozen_string_literal: true
2
- require "rails/generators/active_record"
3
-
4
- module Altcha
5
- module Generators
6
- class InstallGenerator < ActiveRecord::Generators::Base
7
- desc "Installs Altcha for Rails and generates a model, a controller and a route"
8
- argument :name, type: :string, default: "Altcha"
9
-
10
- source_root File.expand_path("templates", __dir__)
11
-
12
- def create_model
13
- copy_file "models/altcha_solution.rb", "app/models/altcha_solution.rb"
14
- end
15
-
16
- def create_controller
17
- copy_file "controllers/altcha_controller.rb", "app/controllers/altcha_controller.rb"
18
- end
19
-
20
- def create_initializer
21
- copy_file "initializers/altcha.rb", "config/initializers/altcha.rb"
22
- end
23
-
24
- def setup_routes
25
- route "get '/altcha', to: 'altcha#new'"
26
- end
27
-
28
- def create_migrations
29
- migration_template "migrations/create_altcha_solutions.rb.erb", "db/migrate/create_altcha_solutions.rb"
30
- end
31
- end
32
- end
33
- end
@@ -1,5 +0,0 @@
1
- class AltchaController < ApplicationController
2
- def new
3
- render json: Altcha::Challenge.create.to_json
4
- end
5
- end
@@ -1,8 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- Altcha.setup do |config|
4
- config.algorithm = 'SHA-256'
5
- config.num_range = (50_000..500_000)
6
- config.timeout = 5.minutes
7
- config.hmac_key = 'change-me'
8
- end
@@ -1,15 +0,0 @@
1
- class CreateAltchaSolutions < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version.to_s %>]
2
- def change
3
- create_table(:altcha_solutions) do |t|
4
- t.string :algorithm
5
- t.string :challenge
6
- t.string :salt
7
- t.string :signature
8
- t.integer :number
9
-
10
- t.timestamps
11
- end
12
-
13
- add_index :altcha_solutions, [ :algorithm, :challenge, :salt, :signature, :number ], unique: true, name: 'index_altcha_solutions'
14
- end
15
- end
@@ -1,28 +0,0 @@
1
- class AltchaSolution < ApplicationRecord
2
- validates :algorithm, :challenge, :salt, :signature, :number, presence: true
3
- attr_accessor :took
4
-
5
- def self.verify_and_save(base64encoded)
6
- p = JSON.parse(Base64.decode64(base64encoded)) rescue nil
7
- return false if p.nil?
8
-
9
- submission = Altcha::Submission.new(p)
10
- return false unless submission.valid?
11
-
12
- solution = self.new(p)
13
-
14
- begin
15
- return solution.save
16
- rescue ActiveRecord::RecordNotUnique
17
- # Replay attack
18
- return false
19
- end
20
- end
21
-
22
- def self.cleanup
23
- # Replay attacks are protected by the time stamp in the salt of the challenge for
24
- # the duration configured in the timeout. All solutions in the database that older
25
- # can be deleted.
26
- AltchaSolution.where('created_at < ?', Time.now - Altcha.timeout).delete_all
27
- end
28
- end