altcha-rails 0.0.5 → 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 +4 -4
- data/.gitignore +1 -0
- data/Gemfile +3 -0
- data/README.md +176 -46
- data/Rakefile +10 -0
- data/altcha-rails.gemspec +19 -9
- data/lib/altcha-rails.rb +130 -39
- data/test/altcha_test.rb +278 -0
- data/test/test_helper.rb +46 -0
- metadata +55 -15
- data/lib/generators/altcha/install/install_generator.rb +0 -33
- data/lib/generators/altcha/install/templates/controllers/altcha_controller.rb +0 -5
- data/lib/generators/altcha/install/templates/initializers/altcha.rb +0 -8
- data/lib/generators/altcha/install/templates/migrations/create_altcha_solutions.rb.erb +0 -15
- data/lib/generators/altcha/install/templates/models/altcha_solution.rb +0 -28
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ba8a3f7a187f4b142c1b28a24d9d144b1fe317127df1a318363ff4eed8572acb
|
|
4
|
+
data.tar.gz: 9f46b25f55a4aaaaa00df542f1a517b1b7d28ba48518f2651b16400c95c65287
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 30e3e105f9d0189b5f86ad0ee6e14fe52989ddf2d08f32750293d8e63316dc1631dfd7b05e0ca0580fcbcfe40f5594bd5a8479290f384fb643f2227257cc8ec4
|
|
7
|
+
data.tar.gz: 9e59a4d3bb6348fb59999a6bc157f52180aa0895fecc87c4034088d65f0477e6e17db87c373a07a4dd078d8e8f7fa3cd6cc534f976fe889cb50ea1823cf338e0
|
data/.gitignore
CHANGED
data/Gemfile
ADDED
data/README.md
CHANGED
|
@@ -4,98 +4,228 @@
|
|
|
4
4
|
|
|
5
5
|
[ALTCHA](https://altcha.org/) is a protocol designed for safeguarding against spam and abuse by utilizing a proof-of-work mechanism. This protocol comprises both a client-facing widget and a server-side verification process.
|
|
6
6
|
|
|
7
|
-
`altcha-
|
|
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
|
|
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
|
|
|
13
13
|
Add this line to your application's Gemfile:
|
|
14
14
|
|
|
15
15
|
```ruby
|
|
16
|
-
gem 'altcha-
|
|
16
|
+
gem 'altcha-rails'
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
Then execute `bundle install
|
|
20
|
-
|
|
21
|
-
Next, run the generator to install the initializer and the controller:
|
|
22
|
-
|
|
23
|
-
```bash
|
|
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).
|
|
19
|
+
Then execute `bundle install`.
|
|
33
20
|
|
|
34
21
|
## Configuration
|
|
35
22
|
|
|
36
|
-
|
|
23
|
+
Create `config/initializers/altcha.rb` with the following configuration options:
|
|
37
24
|
|
|
38
25
|
```ruby
|
|
39
26
|
Altcha.setup do |config|
|
|
40
|
-
config.
|
|
41
|
-
config.
|
|
42
|
-
config.
|
|
43
|
-
config.
|
|
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
|
|
44
32
|
end
|
|
45
33
|
```
|
|
46
34
|
|
|
47
|
-
|
|
48
|
-
It is crucial change the `hmac_key` to a secure value. This key is used to sign the challenge and the response,
|
|
49
|
-
so it must be treated as a secret within your application.
|
|
50
|
-
The `num_range` option specifies the range of numbers to use in the challenge and determines the difficulty of the proof-of-work.
|
|
51
|
-
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.
|
|
52
36
|
|
|
53
37
|
## Challenge expiration
|
|
54
38
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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`.
|
|
58
46
|
|
|
59
47
|
As users might complete the captcha before filling out a complex form, the `timeout` should be set to a reasonable
|
|
60
48
|
value.
|
|
61
49
|
|
|
62
50
|
## Replay attacks
|
|
63
51
|
|
|
64
|
-
To also guard against replay attacks within the configured `timeout` period, the gem
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
should be called regularly.
|
|
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.
|
|
69
56
|
|
|
70
|
-
|
|
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.
|
|
71
61
|
|
|
72
|
-
|
|
73
|
-
at this point. Read up on the [ALTCHA documentation](https://altcha.org/docs/website-integration) for more information.
|
|
62
|
+
## Issuing a challenge
|
|
74
63
|
|
|
75
|
-
|
|
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:
|
|
76
65
|
|
|
77
66
|
```erb
|
|
78
|
-
<altcha-widget
|
|
67
|
+
<altcha-widget challenge='<%= Altcha.create_challenge.to_json %>'></altcha-widget>
|
|
79
68
|
```
|
|
80
69
|
|
|
81
|
-
|
|
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.
|
|
82
71
|
|
|
83
|
-
|
|
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:
|
|
84
75
|
|
|
85
76
|
```ruby
|
|
86
77
|
def create
|
|
87
|
-
@model = Model.
|
|
78
|
+
@model = Model.create(model_params)
|
|
79
|
+
|
|
80
|
+
unless Altcha.verify(params.permit(:altcha)[:altcha])
|
|
81
|
+
flash.now[:alert] = 'ALTCHA verification failed.'
|
|
82
|
+
render :new, status: :unprocessable_entity
|
|
83
|
+
return
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# ...
|
|
87
|
+
end
|
|
88
|
+
```
|
|
89
|
+
|
|
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.
|
|
88
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):
|
|
89
195
|
unless AltchaSolution.verify_and_save(params.permit(:altcha)[:altcha])
|
|
90
196
|
flash.now[:alert] = 'ALTCHA verification failed.'
|
|
91
|
-
render :new
|
|
197
|
+
render :new, status: :unprocessable_entity
|
|
92
198
|
return
|
|
199
|
+
end
|
|
93
200
|
|
|
94
|
-
|
|
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
|
|
95
206
|
end
|
|
96
207
|
```
|
|
97
208
|
|
|
98
|
-
|
|
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
|
+
```
|
|
99
229
|
|
|
100
230
|
## Contributing
|
|
101
231
|
|
data/Rakefile
ADDED
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
|
|
5
|
-
s.version
|
|
6
|
-
s.authors
|
|
7
|
-
s.
|
|
8
|
-
s.
|
|
9
|
-
s.
|
|
10
|
-
s.
|
|
11
|
-
s.
|
|
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
|
-
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
@@algorithm = 'SHA-256'
|
|
3
|
+
require "base64"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "json"
|
|
6
|
+
require "openssl"
|
|
7
|
+
require "securerandom"
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
module Altcha
|
|
10
|
+
class ConfigurationError < StandardError; end
|
|
12
11
|
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
class << self
|
|
13
|
+
attr_accessor :algorithm, :max_number, :hmac_key, :timeout, :cache_key_prefix
|
|
14
|
+
end
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
81
|
+
def to_json(*args)
|
|
82
|
+
to_h.to_json(*args)
|
|
39
83
|
end
|
|
40
84
|
end
|
|
41
85
|
|
|
42
86
|
class Submission
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
148
|
+
def secure_compare(a, b)
|
|
149
|
+
return false unless a.bytesize == b.bytesize
|
|
58
150
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
data/test/altcha_test.rb
ADDED
|
@@ -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
|
data/test/test_helper.rb
ADDED
|
@@ -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
|
|
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:
|
|
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
|
-
-
|
|
27
|
-
-
|
|
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:
|
|
52
|
-
signing_key:
|
|
92
|
+
rubygems_version: 4.0.6
|
|
53
93
|
specification_version: 4
|
|
54
|
-
summary:
|
|
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,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
|
|
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
|