active_hashcash 0.4.0 → 0.6.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/AGENTS.md +9 -0
- data/CHANGELOG.md +20 -0
- data/README.md +105 -22
- data/app/assets/javascripts/hashcash.js +215 -173
- data/app/jobs/active_hashcash/reputation/cleanup_job.rb +12 -0
- data/app/jobs/active_hashcash/reputation/update_abuse_score_job.rb +13 -0
- data/app/jobs/active_hashcash/reputation/update_all_scores_job.rb +16 -0
- data/app/jobs/active_hashcash/reputation/update_anonymous_score_job.rb +19 -0
- data/app/jobs/active_hashcash/reputation/update_attack_score_job.rb +15 -0
- data/app/jobs/active_hashcash/reputation/update_score_job.rb +64 -0
- data/app/models/active_hashcash/reputation/ipv4_address.rb +44 -0
- data/app/models/active_hashcash/reputation/ipv4_range.rb +60 -0
- data/app/models/active_hashcash/reputation.rb +15 -0
- data/app/models/active_hashcash/stamp.rb +25 -2
- data/db/migrate/20260831130000_create_active_hashcash_reputation_ipv4s.rb +54 -0
- data/lib/active_hashcash/engine.rb +6 -0
- data/lib/active_hashcash/version.rb +1 -1
- data/lib/active_hashcash.rb +61 -14
- metadata +13 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0c53df9a89fac1a14b89fb109520d7b2c35d1455b3b2624d9200186d9a295cf7
|
|
4
|
+
data.tar.gz: d090c36da4d4d17f80b5d7a653cbe2544fc311cdcf63a97bd3b46d1cf944631b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cde7a3bfff3389e1761e461dad1a44a2fd480078fd5fb80362c59f5a4a8f8601feff6a9bf28785bab757abf725213060bffaa724815c8a131b3424f7145bedb5
|
|
7
|
+
data.tar.gz: 3f1c4d3842d4ef2aa871c986d36a9557c3c2b5f94145cdb65375dff56bf3a8e8bd5a24e718783962c3f4a5aaacfef709ae5c2f063b79794fec728ac3c9ef1aab
|
data/AGENTS.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Agents - ActiveHashcas
|
|
2
|
+
|
|
3
|
+
## Coding instructions
|
|
4
|
+
|
|
5
|
+
- Keep everything as simple as possible
|
|
6
|
+
- Do not extract methods in modules from your initiative
|
|
7
|
+
- Do not use service objects
|
|
8
|
+
- Do not rescue exceptions unless a specific behavior is required
|
|
9
|
+
- Do not test private methods
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog of ActiveHashcash
|
|
2
2
|
|
|
3
|
+
## 0.6.0 (2026-09-22)
|
|
4
|
+
|
|
5
|
+
- Penalize bad IPs reported by FireHOL lists. For this feature to do anything you must run the migration and schedule the sync job from a cron:
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
rails active_hashcash:install:migrations
|
|
9
|
+
rails db:migrate
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Then schedule `ActiveHashcash::Reputation::UpdateAllScoresJob.perform_later` anywhere from once per hour to once per day.
|
|
13
|
+
|
|
14
|
+
- Add `ActiveHashcash.throttle_rules` to slow down pushy IPs
|
|
15
|
+
|
|
16
|
+
## 0.5.0 (2026-08-08)
|
|
17
|
+
|
|
18
|
+
- Fix stamp date mismatch by using server date instead of client
|
|
19
|
+
- Replace SHA-1 with SHA-256 for proof-of-work stamps
|
|
20
|
+
- Mine stamps in a Web Worker using pure JS SHA-256 (keeps the main thread unblocked)
|
|
21
|
+
- Support SHA-1 fallback on the backend for backward compatibility (via the `ext` stamp field)
|
|
22
|
+
|
|
3
23
|
## 0.4.0 (2025-05-15)
|
|
4
24
|
|
|
5
25
|
- Prevent from password managers to submit the form before the stamp has been computed
|
data/README.md
CHANGED
|
@@ -153,42 +153,125 @@ authenticate :user, -> (u) { u.admin? } do # Supposing there is a User#admin? me
|
|
|
153
153
|
end
|
|
154
154
|
```
|
|
155
155
|
|
|
156
|
-
|
|
156
|
+
## Complexity
|
|
157
157
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
158
|
+
Complexity controls the base proof-of-work difficulty.
|
|
159
|
+
Increasing by one doubles the work time.
|
|
160
|
+
By default its value is 20 and you can change it with `ActiveHashcash.bits = 24` or by overriding the method `hashcash_bits` in the controller.
|
|
161
161
|
|
|
162
|
-
|
|
162
|
+
### Penalties
|
|
163
163
|
|
|
164
|
-
To
|
|
164
|
+
To improve protection, a penalty is added for pushy and bad IPs.
|
|
165
|
+
|
|
166
|
+
### For pushy IPs
|
|
165
167
|
|
|
168
|
+
A penalty is added for IPs which submit valid stamps too fast.
|
|
169
|
+
The goal is to slow down attackers using a botnet.
|
|
170
|
+
The penalty rules can be defined like this.
|
|
171
|
+
|
|
172
|
+
```ruby
|
|
173
|
+
ActiveHashcash.throttle_rules = [
|
|
174
|
+
{period: 1.hour, rate: 0.5},
|
|
175
|
+
{period: 24.hours, rate: 0.25}
|
|
176
|
+
]
|
|
166
177
|
```
|
|
167
|
-
|
|
168
|
-
|
|
178
|
+
|
|
179
|
+
For every valid stamp sent less than an hour ago, a penalty of 0.5 is added.
|
|
180
|
+
Then, for every valid stamp sent between 1 and 24 hours ago a penalty of 0.25 is added.
|
|
181
|
+
Thus, if an IP sent 1 stamp one minute ago, and 3 others few hours ago, it adds a complexity of `(1 * 0.5 + 3 * 0.25).floor # => 1`.
|
|
182
|
+
So next hashcash must have a complexity of `ActiveHashcash.bits + 1`.
|
|
183
|
+
|
|
184
|
+
If you have many users behind the same IP, such as a NAT, you can either lower the rates or disable the penalty.
|
|
185
|
+
In your controller, override the method `hashcash_throttle_penalty`:
|
|
186
|
+
|
|
187
|
+
```ruby
|
|
188
|
+
class SessionController < ApplicationController
|
|
189
|
+
include ActiveHashcash
|
|
190
|
+
|
|
191
|
+
def hashcash_throttle_penalty
|
|
192
|
+
# Only the base complexity (ActiveHashcash.bits) will apply for people with IP 1.2.3.4
|
|
193
|
+
hashcash_ip_address == "1.2.3.4" ? 0 : super
|
|
194
|
+
end
|
|
195
|
+
end
|
|
169
196
|
```
|
|
170
197
|
|
|
171
|
-
|
|
198
|
+
Or, if someone is attacking you from a specific country:
|
|
199
|
+
|
|
200
|
+
```ruby
|
|
201
|
+
class SessionController < ApplicationController
|
|
202
|
+
include ActiveHashcash
|
|
203
|
+
|
|
204
|
+
def hashcash_throttle_penalty
|
|
205
|
+
geoip.country(hashcash_ip_address).country_code == "XX" ? super + 2 : super
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
```
|
|
172
209
|
|
|
173
|
-
|
|
174
|
-
The user won't wait that long, since he needs to fill the form while the problem is solving.
|
|
175
|
-
However, if your application includes people with slow and old devices, then consider lowering this value, to 16 or 18.
|
|
210
|
+
### For IPs with poor reputation
|
|
176
211
|
|
|
177
|
-
|
|
212
|
+
The following lists are used to increase the complexity for bad IPs:
|
|
178
213
|
|
|
179
|
-
|
|
180
|
-
|
|
214
|
+
- [FireHOL abusers 1 day](https://iplists.firehol.org/files/firehol_abusers_1d.netset)
|
|
215
|
+
- [FireHOL abusers 30 days](https://iplists.firehol.org/files/firehol_abusers_30d.netset)
|
|
216
|
+
- [FireHOL anonymous](https://iplists.firehol.org/files/firehol_anonymous.netset) (Tor, public proxies, etc.)
|
|
217
|
+
- [FireHOL level 1](https://iplists.firehol.org/files/firehol_level1.netset)
|
|
218
|
+
- [FireHOL level 2](https://iplists.firehol.org/files/firehol_level2.netset)
|
|
219
|
+
- [FireHOL level 3](https://iplists.firehol.org/files/firehol_level3.netset)
|
|
220
|
+
- [FireHOL level 4](https://iplists.firehol.org/files/firehol_level4.netset)
|
|
221
|
+
|
|
222
|
+
The lists are stored in `ActiveHashcash::Reputation::IPv4Address` (single IPs) and `ActiveHashcash::Reputation::IPv4Range` (CIDR ranges). For this feature to do anything you must run the migration and schedule `ActiveHashcash::Reputation::UpdateAllScoresJob.perform_later` from a cron anywhere from once per hour to once per day.
|
|
223
|
+
If you update too often you might be blocked.
|
|
224
|
+
Updating reputation for ranges probably will not work before Rails 7.1 because of composite primary key + `upsert_all`.
|
|
225
|
+
|
|
226
|
+
If you don't trust these external lists, don't schedule that job and delete all records of `ActiveHashcash::Reputation::IPv4Address` and `ActiveHashcash::Reputation::IPv4Range`.
|
|
227
|
+
|
|
228
|
+
The penalty can be high enough that the proof of work is almost impossible.
|
|
229
|
+
That is useful to neutralize bots.
|
|
230
|
+
|
|
231
|
+
## Testing Your Application
|
|
232
|
+
|
|
233
|
+
Browser tests submit the real form, so they have to compute a real stamp. At the default complexity this adds noticeable time to every submission and slows down your suite. Drop the complexity in the test environment so it finishes almost instantly:
|
|
234
|
+
|
|
235
|
+
```ruby
|
|
236
|
+
# spec/rails_helper.rb (RSpec) or test/test_helper.rb (Minitest)
|
|
237
|
+
ActiveHashcash.bits = 1
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Use `ActiveHashcash::Stamp.mint` to submit hashcash to your sensitive forms:
|
|
241
|
+
|
|
242
|
+
```ruby
|
|
243
|
+
class SessionControllerTest < ActionDispatch::IntegrationTest
|
|
244
|
+
def test_create
|
|
245
|
+
# ...
|
|
246
|
+
hashcash = ActiveHashcash::Stamp.mint(host).to_s
|
|
247
|
+
post(session_path, params: {email: email, password: password, hashcash: hashcash})
|
|
248
|
+
# ...
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
```
|
|
181
252
|
|
|
182
253
|
## Limitations
|
|
183
254
|
|
|
184
|
-
The JavaScript implementation is
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
255
|
+
The JavaScript implementation is slower than the official C version.
|
|
256
|
+
It uses a pure JS SHA-256 implementation running inside a Web Worker, which keeps the main thread responsive while mining.
|
|
257
|
+
A synchronous tight loop avoids the per-call async overhead of `crypto.subtle.digest()`, making it the fastest browser-side approach across Chrome and Safari.
|
|
258
|
+
|
|
259
|
+
No `crypto.subtle` or secure context (HTTPS) is required, so it works in any environment including plain HTTP during development.
|
|
188
260
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
261
|
+
### Before version 0.3.0
|
|
262
|
+
|
|
263
|
+
You must have Redis in order to prevent double spent stamps. Otherwise it will be useless.
|
|
264
|
+
It automatically tries to connect with the environment variables `ACTIVE_HASHCASH_REDIS_URL` or `REDIS_URL`.
|
|
265
|
+
You can also manually set the URL with `ActiveHashcash.redis_url = redis://user:password@localhost:6379`.
|
|
266
|
+
|
|
267
|
+
You should call `ActiveHashcash::Store#clean` once a day, to remove expired stamps.
|
|
268
|
+
|
|
269
|
+
To upgrade from 0.2.0 you must run the migration :
|
|
270
|
+
|
|
271
|
+
```
|
|
272
|
+
rails active_hashcash:install:migrations
|
|
273
|
+
rails db:migrate
|
|
274
|
+
```
|
|
192
275
|
|
|
193
276
|
## Contributing
|
|
194
277
|
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
// http://www.hashcash.org/docs/hashcash.html
|
|
2
|
-
// <input type="
|
|
2
|
+
// <input type="hidden" name="hashcash" data-hashcash="{resource: 'site.example', bits: 16}"/>
|
|
3
3
|
Hashcash = function(input) {
|
|
4
4
|
options = JSON.parse(input.getAttribute("data-hashcash"))
|
|
5
5
|
Hashcash.disableParentForm(input, options)
|
|
6
6
|
input.dispatchEvent(new CustomEvent("hashcash:mint", {bubbles: true}))
|
|
7
7
|
|
|
8
8
|
Hashcash.mint(options.resource, options, function(stamp) {
|
|
9
|
+
// Guard against Turbo navigation: if the input is no longer in the DOM,
|
|
10
|
+
// the user has navigated away and we should silently discard the result.
|
|
11
|
+
if (!input.isConnected) return
|
|
12
|
+
|
|
9
13
|
input.value = stamp.toString()
|
|
10
14
|
Hashcash.enableParentForm(input, options)
|
|
11
15
|
input.dispatchEvent(new CustomEvent("hashcash:minted", {bubbles: true, detail: {stamp: stamp}}))
|
|
@@ -20,9 +24,39 @@ Hashcash.setup = function() {
|
|
|
20
24
|
var input = document.querySelector("input#hashcash")
|
|
21
25
|
input && new Hashcash(input)
|
|
22
26
|
} else
|
|
23
|
-
document.addEventListener("DOMContentLoaded", Hashcash.setup
|
|
27
|
+
document.addEventListener("DOMContentLoaded", Hashcash.setup)
|
|
24
28
|
}
|
|
25
29
|
|
|
30
|
+
// Terminate the active worker and clean up its Blob URL.
|
|
31
|
+
Hashcash.cleanup = function() {
|
|
32
|
+
if (Hashcash._worker) {
|
|
33
|
+
Hashcash._worker.terminate()
|
|
34
|
+
Hashcash._worker = null
|
|
35
|
+
}
|
|
36
|
+
if (Hashcash._workerUrl) {
|
|
37
|
+
URL.revokeObjectURL(Hashcash._workerUrl)
|
|
38
|
+
Hashcash._workerUrl = null
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Turbo Drive: terminate the worker when navigating away so it doesn't
|
|
43
|
+
// complete after the page has changed, and restore the form state for
|
|
44
|
+
// Turbo's page cache so the snapshot doesn't have disabled buttons.
|
|
45
|
+
document.addEventListener("turbo:before-visit", Hashcash.cleanup)
|
|
46
|
+
document.addEventListener("turbo:before-cache", function() {
|
|
47
|
+
Hashcash.cleanup()
|
|
48
|
+
var input = document.querySelector("input#hashcash")
|
|
49
|
+
if (input && input.form) {
|
|
50
|
+
input.value = ""
|
|
51
|
+
input.form.querySelectorAll("[type=submit]").forEach(function(submit) {
|
|
52
|
+
if (submit.originalValue) {
|
|
53
|
+
Hashcash.setSubmitText(submit, submit.originalValue)
|
|
54
|
+
}
|
|
55
|
+
submit.disabled = null
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
|
|
26
60
|
Hashcash.setSubmitText = function(submit, text) {
|
|
27
61
|
if (!text) {
|
|
28
62
|
return
|
|
@@ -57,62 +91,45 @@ Hashcash.prototype.preventFromAutoSubmitFromPasswordManagers = function(event) {
|
|
|
57
91
|
Hashcash.default = {
|
|
58
92
|
version: 1,
|
|
59
93
|
bits: 20,
|
|
60
|
-
extension: null,
|
|
61
94
|
}
|
|
62
95
|
|
|
63
96
|
Hashcash.mint = function(resource, options, callback) {
|
|
64
|
-
// Format date to YYMMDD
|
|
65
|
-
var date = new Date
|
|
66
|
-
var year = date.getFullYear().toString()
|
|
67
|
-
year = year.slice(year.length - 2, year.length)
|
|
68
|
-
var month = (date.getMonth() + 1).toString().padStart(2, "0")
|
|
69
|
-
var day = date.getDate().toString().padStart(2, "0")
|
|
70
|
-
|
|
71
97
|
var stamp = new Hashcash.Stamp(
|
|
72
98
|
options.version || Hashcash.default.version,
|
|
73
99
|
options.bits || Hashcash.default.bits,
|
|
74
|
-
options.date ||
|
|
100
|
+
options.date || Hashcash.formatToday(),
|
|
75
101
|
resource,
|
|
76
|
-
options.
|
|
77
|
-
options.rand || Math.random().toString(36).substr(2, 10),
|
|
102
|
+
options.rand || Math.random().toString(36).substr(2, 10)
|
|
78
103
|
)
|
|
79
104
|
return stamp.work(callback)
|
|
80
105
|
}
|
|
81
106
|
|
|
82
|
-
|
|
107
|
+
// Format date to YYMMDD
|
|
108
|
+
Hashcash.formatToday = function() {
|
|
109
|
+
var date = new Date
|
|
110
|
+
var year = date.getFullYear().toString()
|
|
111
|
+
year = year.slice(year.length - 2, year.length)
|
|
112
|
+
var month = (date.getMonth() + 1).toString().padStart(2, "0")
|
|
113
|
+
var day = date.getDate().toString().padStart(2, "0")
|
|
114
|
+
return year + month + day
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
Hashcash.Stamp = function(version, bits, date, resource, rand, counter) {
|
|
83
118
|
this.version = version
|
|
84
119
|
this.bits = bits
|
|
85
120
|
this.date = date
|
|
86
121
|
this.resource = resource
|
|
87
|
-
this.extension = extension
|
|
88
122
|
this.rand = rand
|
|
89
|
-
this.counter = counter
|
|
123
|
+
this.counter = counter || 0
|
|
90
124
|
}
|
|
91
125
|
|
|
92
126
|
Hashcash.Stamp.parse = function(string) {
|
|
93
127
|
var args = string.split(":")
|
|
94
|
-
return new Hashcash.Stamp(args[0], args[1], args[2], args[3], args[
|
|
128
|
+
return new Hashcash.Stamp(args[0], args[1], args[2], args[3], args[5], args[6])
|
|
95
129
|
}
|
|
96
130
|
|
|
97
131
|
Hashcash.Stamp.prototype.toString = function() {
|
|
98
|
-
return [this.version, this.bits, this.date, this.resource,
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// Trigger the given callback when the problem is solved.
|
|
102
|
-
// In order to not freeze the page, setTimeout is called every 100ms to let some CPU to other tasks.
|
|
103
|
-
Hashcash.Stamp.prototype.work = function(callback) {
|
|
104
|
-
this.startClock()
|
|
105
|
-
var timer = performance.now()
|
|
106
|
-
while (!this.check())
|
|
107
|
-
if (this.counter++ && performance.now() - timer > 100)
|
|
108
|
-
return setTimeout(this.work.bind(this), 0, callback)
|
|
109
|
-
this.stopClock()
|
|
110
|
-
callback(this)
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
Hashcash.Stamp.prototype.check = function() {
|
|
114
|
-
var array = Hashcash.sha1(this.toString())
|
|
115
|
-
return array[0] >> (160-this.bits) == 0
|
|
132
|
+
return [this.version, this.bits, this.date, this.resource, "sha256", this.rand, this.counter].join(":")
|
|
116
133
|
}
|
|
117
134
|
|
|
118
135
|
Hashcash.Stamp.prototype.startClock = function() {
|
|
@@ -126,151 +143,176 @@ Hashcash.Stamp.prototype.stopClock = function() {
|
|
|
126
143
|
console.debug("Hashcash " + this.toString() + " minted in " + duration + "ms (" + speed + " per seconds)")
|
|
127
144
|
}
|
|
128
145
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
var
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
E = D;
|
|
192
|
-
D = C;
|
|
193
|
-
C = rotate_left(B, 30);
|
|
194
|
-
B = A;
|
|
195
|
-
A = temp;
|
|
146
|
+
// Mine a valid stamp using a Web Worker with a pure JS SHA-256 implementation.
|
|
147
|
+
// The worker is inlined as a Blob URL so no extra file needs to be served.
|
|
148
|
+
// Using a Web Worker keeps the main thread completely unblocked while mining.
|
|
149
|
+
// A synchronous SHA-256 in a tight loop is faster than async crypto.subtle
|
|
150
|
+
// because it avoids the per-call Promise/microtask overhead entirely.
|
|
151
|
+
Hashcash.Stamp.prototype.work = function(callback) {
|
|
152
|
+
this.startClock()
|
|
153
|
+
var self = this
|
|
154
|
+
|
|
155
|
+
var workerCode = function() {
|
|
156
|
+
// SHA-256 round constants (FIPS 180-4 §4.2.2)
|
|
157
|
+
var K = new Uint32Array([
|
|
158
|
+
0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
|
|
159
|
+
0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
|
|
160
|
+
0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
|
|
161
|
+
0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
|
|
162
|
+
0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
|
|
163
|
+
0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
|
|
164
|
+
0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
|
|
165
|
+
0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2
|
|
166
|
+
]);
|
|
167
|
+
|
|
168
|
+
// Compute SHA-256 over the first `len` bytes of `bytes`. Returns an array
|
|
169
|
+
// of 8 big-endian 32-bit integers (the 256-bit digest).
|
|
170
|
+
function sha256bytes(bytes, len) {
|
|
171
|
+
// Pad to a multiple of 64 bytes: append 0x80, then zeros, then 64-bit
|
|
172
|
+
// big-endian bit length (we only use the low 32 bits since stamp strings
|
|
173
|
+
// are always shorter than 512 MB).
|
|
174
|
+
var padded = new Uint8Array(((len + 9 + 63) & ~63));
|
|
175
|
+
for (var i = 0; i < len; i++) padded[i] = bytes[i];
|
|
176
|
+
padded[len] = 0x80;
|
|
177
|
+
var bitLen = len * 8;
|
|
178
|
+
var pLen = padded.length;
|
|
179
|
+
padded[pLen - 4] = (bitLen >>> 24) & 0xff;
|
|
180
|
+
padded[pLen - 3] = (bitLen >>> 16) & 0xff;
|
|
181
|
+
padded[pLen - 2] = (bitLen >>> 8) & 0xff;
|
|
182
|
+
padded[pLen - 1] = bitLen & 0xff;
|
|
183
|
+
|
|
184
|
+
// Initial hash values (FIPS 180-4 §5.3.3)
|
|
185
|
+
var H0 = 0x6a09e667, H1 = 0xbb67ae85, H2 = 0x3c6ef372, H3 = 0xa54ff53a;
|
|
186
|
+
var H4 = 0x510e527f, H5 = 0x9b05688c, H6 = 0x1f83d9ab, H7 = 0x5be0cd19;
|
|
187
|
+
var W = new Int32Array(64);
|
|
188
|
+
|
|
189
|
+
// Process each 512-bit (64-byte) block
|
|
190
|
+
for (var off = 0; off < pLen; off += 64) {
|
|
191
|
+
for (var t = 0; t < 16; t++) {
|
|
192
|
+
W[t] = (padded[off+t*4]<<24)|(padded[off+t*4+1]<<16)|(padded[off+t*4+2]<<8)|padded[off+t*4+3];
|
|
193
|
+
}
|
|
194
|
+
for (var t = 16; t < 64; t++) {
|
|
195
|
+
var w15 = W[t-15], w2 = W[t-2];
|
|
196
|
+
W[t] = (W[t-16] + (((w15>>>7)|(w15<<25))^((w15>>>18)|(w15<<14))^(w15>>>3)) + W[t-7] + (((w2>>>17)|(w2<<15))^((w2>>>19)|(w2<<13))^(w2>>>10))) | 0;
|
|
197
|
+
}
|
|
198
|
+
var a=H0,b=H1,c=H2,d=H3,e=H4,f=H5,g=H6,h=H7;
|
|
199
|
+
for (var t = 0; t < 64; t++) {
|
|
200
|
+
var t1 = (h + (((e>>>6)|(e<<26))^((e>>>11)|(e<<21))^((e>>>25)|(e<<7))) + ((e&f)^(~e&g)) + K[t] + W[t]) | 0;
|
|
201
|
+
var t2 = ((((a>>>2)|(a<<30))^((a>>>13)|(a<<19))^((a>>>22)|(a<<10))) + ((a&b)^(a&c)^(b&c))) | 0;
|
|
202
|
+
h=g; g=f; f=e; e=(d+t1)|0; d=c; c=b; b=a; a=(t1+t2)|0;
|
|
203
|
+
}
|
|
204
|
+
H0=(H0+a)|0; H1=(H1+b)|0; H2=(H2+c)|0; H3=(H3+d)|0;
|
|
205
|
+
H4=(H4+e)|0; H5=(H5+f)|0; H6=(H6+g)|0; H7=(H7+h)|0;
|
|
206
|
+
}
|
|
207
|
+
return [H0, H1, H2, H3, H4, H5, H6, H7];
|
|
196
208
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
209
|
+
|
|
210
|
+
// Encode a string as bytes into a pre-allocated buffer at `offset`.
|
|
211
|
+
// Handles ASCII and basic UTF-8 (BMP only).
|
|
212
|
+
function strToBytes(str, out, offset) {
|
|
213
|
+
for (var i = 0; i < str.length; i++) {
|
|
214
|
+
var c = str.charCodeAt(i);
|
|
215
|
+
if (c < 128) out[offset++] = c;
|
|
216
|
+
else if (c < 2048) { out[offset++] = (c>>6)|192; out[offset++] = (c&63)|128; }
|
|
217
|
+
else { out[offset++] = (c>>12)|224; out[offset++] = ((c>>6)&63)|128; out[offset++] = (c&63)|128; }
|
|
218
|
+
}
|
|
219
|
+
return offset;
|
|
204
220
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
221
|
+
|
|
222
|
+
// Write the decimal digits of `n` as ASCII bytes into `out` at `offset`.
|
|
223
|
+
// Avoids string allocation in the hot loop.
|
|
224
|
+
function numToBytes(n, out, offset) {
|
|
225
|
+
if (n === 0) { out[offset] = 48; return offset + 1; }
|
|
226
|
+
var digs = [];
|
|
227
|
+
while (n > 0) { digs.push(48 + (n % 10)); n = (n / 10) | 0; }
|
|
228
|
+
for (var i = digs.length - 1; i >= 0; i--) out[offset++] = digs[i];
|
|
229
|
+
return offset;
|
|
212
230
|
}
|
|
213
|
-
H0 = (H0 + A) & 0x0ffffffff;
|
|
214
|
-
H1 = (H1 + B) & 0x0ffffffff;
|
|
215
|
-
H2 = (H2 + C) & 0x0ffffffff;
|
|
216
|
-
H3 = (H3 + D) & 0x0ffffffff;
|
|
217
|
-
H4 = (H4 + E) & 0x0ffffffff;
|
|
218
|
-
}
|
|
219
|
-
return [H0, H1, H2, H3, H4]
|
|
220
|
-
}
|
|
221
231
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
232
|
+
self.addEventListener("message", function(e) {
|
|
233
|
+
var d = e.data;
|
|
234
|
+
var prefix = d.prefix + ":";
|
|
235
|
+
var bits = d.bits;
|
|
236
|
+
var counter = 0;
|
|
227
237
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
238
|
+
// Pre-compute how many leading zero bytes and remaining bits to check
|
|
239
|
+
var fullBytes = Math.floor(bits / 8);
|
|
240
|
+
var remBits = bits % 8;
|
|
241
|
+
var mask = remBits > 0 ? (0xFF << (8 - remBits)) & 0xFF : 0;
|
|
242
|
+
|
|
243
|
+
// Pre-encode the stamp prefix (everything before the counter) into a
|
|
244
|
+
// reusable byte buffer. Only the counter digits change per iteration.
|
|
245
|
+
var buf = new Uint8Array(prefix.length + 12);
|
|
246
|
+
var prefixLen = strToBytes(prefix, buf, 0);
|
|
247
|
+
|
|
248
|
+
// Yield every 65536 iterations so the worker stays terminable.
|
|
249
|
+
var YIELD = 65536;
|
|
250
|
+
|
|
251
|
+
function mine() {
|
|
252
|
+
var end = counter + YIELD;
|
|
253
|
+
while (counter < end) {
|
|
254
|
+
var len = numToBytes(counter, buf, prefixLen);
|
|
255
|
+
var H = sha256bytes(buf, len);
|
|
256
|
+
|
|
257
|
+
// Check leading zero bits by walking the digest words byte-by-byte.
|
|
258
|
+
var ok = true, byteIdx = 0;
|
|
259
|
+
check:
|
|
260
|
+
for (var w = 0; w < 8 && byteIdx <= fullBytes; w++) {
|
|
261
|
+
var word = H[w];
|
|
262
|
+
for (var s = 24; s >= 0; s -= 8) {
|
|
263
|
+
var bv = (word >>> s) & 0xFF;
|
|
264
|
+
if (byteIdx < fullBytes) {
|
|
265
|
+
if (bv !== 0) { ok = false; break check; }
|
|
266
|
+
} else if (byteIdx === fullBytes && remBits > 0) {
|
|
267
|
+
if ((bv & mask) !== 0) ok = false;
|
|
268
|
+
break check;
|
|
269
|
+
} else { break check; }
|
|
270
|
+
byteIdx++;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (ok) { self.postMessage({ found: true, counter: counter }); return; }
|
|
274
|
+
counter++;
|
|
275
|
+
}
|
|
276
|
+
setTimeout(mine, 0);
|
|
277
|
+
}
|
|
278
|
+
mine();
|
|
279
|
+
});
|
|
253
280
|
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
281
|
+
|
|
282
|
+
// Clean up any previous worker (e.g. Turbo restoring a cached page)
|
|
283
|
+
Hashcash.cleanup()
|
|
284
|
+
|
|
285
|
+
var blob = new Blob(
|
|
286
|
+
["(" + workerCode.toString() + ")()"],
|
|
287
|
+
{type: "application/javascript"}
|
|
288
|
+
)
|
|
289
|
+
var workerUrl = URL.createObjectURL(blob)
|
|
290
|
+
var worker = new Worker(workerUrl)
|
|
291
|
+
|
|
292
|
+
// Track the active worker so Hashcash.cleanup() can terminate it
|
|
293
|
+
Hashcash._worker = worker
|
|
294
|
+
Hashcash._workerUrl = workerUrl
|
|
295
|
+
|
|
296
|
+
worker.onmessage = function(e) {
|
|
297
|
+
if (e.data.found) {
|
|
298
|
+
Hashcash._worker = null
|
|
299
|
+
Hashcash._workerUrl = null
|
|
300
|
+
self.counter = e.data.counter
|
|
301
|
+
self.stopClock()
|
|
302
|
+
worker.terminate()
|
|
303
|
+
URL.revokeObjectURL(workerUrl)
|
|
304
|
+
callback(self)
|
|
271
305
|
}
|
|
272
306
|
}
|
|
273
|
-
|
|
274
|
-
|
|
307
|
+
|
|
308
|
+
// Build the prefix once and send it to the worker. The worker appends the
|
|
309
|
+
// counter on each iteration, avoiding repeated string building.
|
|
310
|
+
var prefix = [this.version, this.bits, this.date, this.resource, "sha256", this.rand].join(":")
|
|
311
|
+
|
|
312
|
+
worker.postMessage({
|
|
313
|
+
prefix: prefix,
|
|
314
|
+
bits: parseInt(this.bits, 10)
|
|
315
|
+
})
|
|
316
|
+
}
|
|
275
317
|
|
|
276
318
|
Hashcash.setup()
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveHashcash
|
|
4
|
+
module Reputation
|
|
5
|
+
class UpdateAbuseScoreJob < UpdateScoreJob
|
|
6
|
+
# Higher scores first so upsert_score keeps the max via uniq.
|
|
7
|
+
URLS = {
|
|
8
|
+
"https://iplists.firehol.org/files/firehol_abusers_1d.netset" => 2,
|
|
9
|
+
"https://iplists.firehol.org/files/firehol_abusers_30d.netset" => 1
|
|
10
|
+
}.freeze
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveHashcash
|
|
4
|
+
module Reputation
|
|
5
|
+
class UpdateAllScoresJob < ApplicationJob
|
|
6
|
+
DEFAULT_JOBS = [UpdateAbuseScoreJob, UpdateAnonymousScoreJob, UpdateAttackScoreJob, CleanupJob].freeze
|
|
7
|
+
|
|
8
|
+
def perform(jobs = DEFAULT_JOBS.dup)
|
|
9
|
+
if (job = jobs.shift)
|
|
10
|
+
job.perform_now
|
|
11
|
+
self.class.perform_later(jobs) if jobs.any?
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveHashcash
|
|
4
|
+
module Reputation
|
|
5
|
+
class UpdateAnonymousScoreJob < UpdateScoreJob
|
|
6
|
+
URLS = {
|
|
7
|
+
"https://iplists.firehol.org/files/firehol_anonymous.netset" => 1
|
|
8
|
+
}.freeze
|
|
9
|
+
|
|
10
|
+
def max_body_size
|
|
11
|
+
50.megabytes
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def read_timeout
|
|
15
|
+
20
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveHashcash
|
|
4
|
+
module Reputation
|
|
5
|
+
class UpdateAttackScoreJob < UpdateScoreJob
|
|
6
|
+
# Higher scores first so upsert_score keeps the max via uniq.
|
|
7
|
+
URLS = {
|
|
8
|
+
"https://iplists.firehol.org/files/firehol_level1.netset" => 4,
|
|
9
|
+
"https://iplists.firehol.org/files/firehol_level2.netset" => 3,
|
|
10
|
+
"https://iplists.firehol.org/files/firehol_level3.netset" => 2,
|
|
11
|
+
"https://iplists.firehol.org/files/firehol_level4.netset" => 1
|
|
12
|
+
}.freeze
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ipaddr"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module ActiveHashcash
|
|
8
|
+
module Reputation
|
|
9
|
+
class UpdateScoreJob < ApplicationJob
|
|
10
|
+
def perform
|
|
11
|
+
entries = self.class::URLS.flat_map { |url, score| normalize(fetch(url), score) }
|
|
12
|
+
raise "Empty list" if entries.empty?
|
|
13
|
+
addresses, ranges = entries.partition { |ip, _| ip.prefix == 32 }
|
|
14
|
+
IPv4Address.transaction do
|
|
15
|
+
IPv4Address.reset_score(score_name, addresses)
|
|
16
|
+
IPv4Range.reset_score(score_name, ranges)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def score_name
|
|
21
|
+
self.class.name[/Update(\w*)ScoreJob/, 1].downcase.to_sym
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def fetch(url)
|
|
25
|
+
uri = URI(url)
|
|
26
|
+
body = +""
|
|
27
|
+
|
|
28
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: open_timeout, read_timeout: read_timeout) do |http|
|
|
29
|
+
http.request(Net::HTTP::Get.new(uri)) do |response|
|
|
30
|
+
response.error! unless response.is_a?(Net::HTTPSuccess)
|
|
31
|
+
response.read_body do |chunk|
|
|
32
|
+
body << chunk
|
|
33
|
+
raise "Response body exceeds #{max_body_size} bytes" if body.bytesize > max_body_size
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
body
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def open_timeout
|
|
42
|
+
5
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def read_timeout
|
|
46
|
+
10
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def max_body_size
|
|
50
|
+
10.megabytes
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def normalize(body, score)
|
|
54
|
+
body.each_line.filter_map do |line|
|
|
55
|
+
next if (line = line.strip).blank? || line.start_with?("#", ";")
|
|
56
|
+
next if (ip = IPAddr.new(line)).private? || ip.loopback? || ip.link_local? || !ip.ipv4? || ip.prefix < IPv4Range::MIN_PREFIX
|
|
57
|
+
[ip, score]
|
|
58
|
+
rescue IPAddr::InvalidAddressError
|
|
59
|
+
next
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ipaddr"
|
|
4
|
+
|
|
5
|
+
module ActiveHashcash
|
|
6
|
+
module Reputation
|
|
7
|
+
class IPv4Address < ApplicationRecord
|
|
8
|
+
self.table_name = "active_hashcash_reputation_ipv4_addresses"
|
|
9
|
+
|
|
10
|
+
validates :id, presence: true, length: {is: 4}
|
|
11
|
+
validates :anonymous_score, inclusion: {in: 0..1}
|
|
12
|
+
validates :abuse_score, inclusion: {in: 0..2}
|
|
13
|
+
validates :attack_score, inclusion: {in: 0..4}
|
|
14
|
+
|
|
15
|
+
def self.scores(ip)
|
|
16
|
+
abuse, anonymous, attack = where(id: ActiveRecord::Type::Binary.new.serialize(IPAddr.new(ip).hton)).pick(:abuse_score, :anonymous_score, :attack_score)
|
|
17
|
+
{abuse: abuse || 0, anonymous: anonymous || 0, attack: attack || 0}
|
|
18
|
+
rescue IPAddr::InvalidAddressError
|
|
19
|
+
{abuse: 0, anonymous: 0, attack: 0}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.reset_score(name, entries)
|
|
23
|
+
column = :"#{name}_score"
|
|
24
|
+
entries.uniq! { |ip, _| ip }
|
|
25
|
+
transaction do
|
|
26
|
+
where(column => 1..).update_all(column => 0)
|
|
27
|
+
entries.each_slice(10_000) { |batch| upsert_score(column, batch) }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.upsert_score(column, entries)
|
|
32
|
+
upsert_all(
|
|
33
|
+
entries.map { |ip, value| {id: ip.hton, column => value} },
|
|
34
|
+
record_timestamps: false,
|
|
35
|
+
update_only: [column]
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.delete_zero_scores
|
|
40
|
+
where(anonymous_score: 0, abuse_score: 0, attack_score: 0).delete_all
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ipaddr"
|
|
4
|
+
|
|
5
|
+
module ActiveHashcash
|
|
6
|
+
module Reputation
|
|
7
|
+
class IPv4Range < ApplicationRecord
|
|
8
|
+
self.table_name = "active_hashcash_reputation_ipv4_ranges"
|
|
9
|
+
|
|
10
|
+
MIN_PREFIX = 16 # Reject nets too large
|
|
11
|
+
|
|
12
|
+
validates :first_address, :last_address, presence: true, length: {is: 4}
|
|
13
|
+
validates :anonymous_score, inclusion: {in: 0..1}
|
|
14
|
+
validates :abuse_score, inclusion: {in: 0..2}
|
|
15
|
+
validates :attack_score, inclusion: {in: 0..4}
|
|
16
|
+
|
|
17
|
+
# PK probes for ancestor CIDRs /MIN_PREFIX../32 (wider prefixes are ignored).
|
|
18
|
+
scope :by_address, -> (string) {
|
|
19
|
+
ip = IPAddr.new(string)
|
|
20
|
+
binary = ActiveRecord::Type::Binary.new
|
|
21
|
+
pairs = (MIN_PREFIX..32).map do |prefix|
|
|
22
|
+
range = IPAddr.new("#{ip}/#{prefix}").to_range
|
|
23
|
+
[binary.serialize(range.first.hton), binary.serialize(range.last.hton)]
|
|
24
|
+
end
|
|
25
|
+
where(pairs.map { "(first_address = ? AND last_address = ?)" }.join(" OR "), *pairs.flatten)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
def self.scores(ip)
|
|
29
|
+
abuse, anonymous, attack = by_address(ip).pick(Arel.sql("max(abuse_score), max(anonymous_score), max(attack_score)"))
|
|
30
|
+
{abuse: abuse || 0, anonymous: anonymous || 0, attack: attack || 0}
|
|
31
|
+
rescue IPAddr::InvalidAddressError
|
|
32
|
+
{abuse: 0, anonymous: 0, attack: 0}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.reset_score(name, entries)
|
|
36
|
+
column = :"#{name}_score"
|
|
37
|
+
entries.uniq! { |ip, _| ip }
|
|
38
|
+
transaction do
|
|
39
|
+
where(column => 1..).update_all(column => 0)
|
|
40
|
+
entries.each_slice(10_000) { |batch| upsert_score(column, batch) }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.upsert_score(column, entries)
|
|
45
|
+
upsert_all(
|
|
46
|
+
entries.map do |ip, value|
|
|
47
|
+
range = ip.to_range
|
|
48
|
+
{first_address: range.first.hton, last_address: range.last.hton, column => value}
|
|
49
|
+
end,
|
|
50
|
+
record_timestamps: false,
|
|
51
|
+
update_only: [column]
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.delete_zero_scores
|
|
56
|
+
where(anonymous_score: 0, abuse_score: 0, attack_score: 0).delete_all
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveHashcash
|
|
4
|
+
module Reputation
|
|
5
|
+
def self.scores(ip)
|
|
6
|
+
address = IPv4Address.scores(ip)
|
|
7
|
+
range = IPv4Range.scores(ip)
|
|
8
|
+
{
|
|
9
|
+
abuse: [address[:abuse], range[:abuse]].max,
|
|
10
|
+
anonymous: [address[:anonymous], range[:anonymous]].max,
|
|
11
|
+
attack: [address[:attack], range[:attack]].max
|
|
12
|
+
}
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -26,6 +26,24 @@ module ActiveHashcash
|
|
|
26
26
|
scope
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
+
# Returns stamp counts per period for disjoint windows.
|
|
30
|
+
# +periods+ must be sorted shortest to longest (as ensured by ActiveHashcash.throttle_rules=).
|
|
31
|
+
def self.sum_by_periods(periods)
|
|
32
|
+
return [] if periods.blank?
|
|
33
|
+
|
|
34
|
+
cutoffs = periods.map(&:ago)
|
|
35
|
+
columns = cutoffs.each_with_index.map do |cutoff, index|
|
|
36
|
+
if index.zero?
|
|
37
|
+
sanitize_sql(["sum(CASE WHEN created_at >= ? THEN 1 ELSE 0 END)", cutoff])
|
|
38
|
+
else
|
|
39
|
+
previous_cutoff = cutoffs[index - 1]
|
|
40
|
+
sanitize_sql(["sum(CASE WHEN created_at < ? AND created_at >= ? THEN 1 ELSE 0 END)", previous_cutoff, cutoff])
|
|
41
|
+
end
|
|
42
|
+
end.join(", ")
|
|
43
|
+
|
|
44
|
+
Array.wrap(where(created_at: cutoffs.min..).pluck(Arel.sql(columns)).first)
|
|
45
|
+
end
|
|
46
|
+
|
|
29
47
|
# Verify and save the hashcash stamp.
|
|
30
48
|
# Saving in the database prevent from double spending the same stamp.
|
|
31
49
|
def self.spend(string, resource, bits, date, options = {})
|
|
@@ -36,7 +54,7 @@ module ActiveHashcash
|
|
|
36
54
|
false
|
|
37
55
|
end
|
|
38
56
|
|
|
39
|
-
#
|
|
57
|
+
# Parse and instantiate a stamp from a string which respects the hashcash format:
|
|
40
58
|
#
|
|
41
59
|
# ver:bits:date:resource:[ext]:rand:counter
|
|
42
60
|
def self.parse(string)
|
|
@@ -51,6 +69,7 @@ module ActiveHashcash
|
|
|
51
69
|
bits: ActiveHashcash.bits,
|
|
52
70
|
date: Date.today.strftime(ActiveHashcash.date_format),
|
|
53
71
|
resource: resource,
|
|
72
|
+
ext: "sha256",
|
|
54
73
|
rand: SecureRandom.alphanumeric(16),
|
|
55
74
|
counter: 0,
|
|
56
75
|
}.merge(attributes)).work
|
|
@@ -62,7 +81,11 @@ module ActiveHashcash
|
|
|
62
81
|
end
|
|
63
82
|
|
|
64
83
|
def authentic?
|
|
65
|
-
|
|
84
|
+
if ext == "sha256"
|
|
85
|
+
Digest::SHA256.hexdigest(to_s).hex >> (256 - bits) == 0
|
|
86
|
+
else
|
|
87
|
+
Digest::SHA1.hexdigest(to_s).hex >> (160 - bits) == 0
|
|
88
|
+
end
|
|
66
89
|
end
|
|
67
90
|
|
|
68
91
|
def verify(resource, bits, date)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# IPv4 reputation addresses and ranges are stored in the database.
|
|
2
|
+
# This migration creates the tables for ActiveHashcash::Reputation::IPv4Address
|
|
3
|
+
# and ActiveHashcash::Reputation::IPv4Range.
|
|
4
|
+
# Run the following commands to add them to your Rails application:
|
|
5
|
+
#
|
|
6
|
+
# rails active_hashcash:install:migrations
|
|
7
|
+
# rails db:migrate
|
|
8
|
+
#
|
|
9
|
+
class CreateActiveHashcashReputationIpv4s < ActiveRecord::Migration[5.2]
|
|
10
|
+
def up
|
|
11
|
+
# uint32 would have been the best choice for storing IPv4, but PostgreSQL does not support unsigned integers.
|
|
12
|
+
# So, 4-byte binary column is a trade off for best efficiency compatible with PostgreSQL, MySQL and SQLite.
|
|
13
|
+
create_table :active_hashcash_reputation_ipv4_addresses, id: false do |t|
|
|
14
|
+
t.binary :id, limit: 4, null: false, primary_key: true
|
|
15
|
+
t.integer :abuse_score, limit: 1, null: false, default: 0
|
|
16
|
+
t.integer :anonymous_score, limit: 1, null: false, default: 0
|
|
17
|
+
t.integer :attack_score, limit: 1, null: false, default: 0
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
create_table :active_hashcash_reputation_ipv4_ranges, primary_key: [:first_address, :last_address] do |t|
|
|
21
|
+
t.binary :first_address, limit: 4, null: false
|
|
22
|
+
t.binary :last_address, limit: 4, null: false
|
|
23
|
+
t.integer :abuse_score, limit: 1, null: false, default: 0
|
|
24
|
+
t.integer :anonymous_score, limit: 1, null: false, default: 0
|
|
25
|
+
t.integer :attack_score, limit: 1, null: false, default: 0
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# For SQLite, save space by suffixing the CREATE TABLE statements by `WITHOUT ROWID`:
|
|
29
|
+
# execute <<-SQL
|
|
30
|
+
# CREATE TABLE IF NOT EXISTS "active_hashcash_reputation_ipv4_addresses" (
|
|
31
|
+
# "id" blob(4) NOT NULL,
|
|
32
|
+
# "abuse_score" integer(1) DEFAULT 0 NOT NULL,
|
|
33
|
+
# "anonymous_score" integer(1) DEFAULT 0 NOT NULL,
|
|
34
|
+
# "attack_score" integer(1) DEFAULT 0 NOT NULL,
|
|
35
|
+
# PRIMARY KEY ("id")
|
|
36
|
+
# ) WITHOUT ROWID;
|
|
37
|
+
# SQL
|
|
38
|
+
# execute <<-SQL
|
|
39
|
+
# CREATE TABLE IF NOT EXISTS "active_hashcash_reputation_ipv4_ranges" (
|
|
40
|
+
# "first_address" blob(4) NOT NULL,
|
|
41
|
+
# "last_address" blob(4) NOT NULL,
|
|
42
|
+
# "abuse_score" integer(1) DEFAULT 0 NOT NULL,
|
|
43
|
+
# "anonymous_score" integer(1) DEFAULT 0 NOT NULL,
|
|
44
|
+
# "attack_score" integer(1) DEFAULT 0 NOT NULL,
|
|
45
|
+
# PRIMARY KEY ("first_address", "last_address")
|
|
46
|
+
# ) WITHOUT ROWID;
|
|
47
|
+
# SQL
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def down
|
|
51
|
+
drop_table :active_hashcash_reputation_ipv4_addresses
|
|
52
|
+
drop_table :active_hashcash_reputation_ipv4_ranges
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -3,5 +3,11 @@ module ActiveHashcash
|
|
|
3
3
|
config.assets.paths << File.expand_path("../..", __FILE__) if config.respond_to?(:assets)
|
|
4
4
|
|
|
5
5
|
isolate_namespace ActiveHashcash
|
|
6
|
+
|
|
7
|
+
initializer "active_hashcash.inflections" do
|
|
8
|
+
ActiveSupport::Inflector.inflections(:en) do |inflect|
|
|
9
|
+
inflect.acronym "IPv4"
|
|
10
|
+
end
|
|
11
|
+
end
|
|
6
12
|
end
|
|
7
13
|
end
|
data/lib/active_hashcash.rb
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require "active_hashcash/version"
|
|
2
4
|
require "active_hashcash/engine"
|
|
3
5
|
|
|
@@ -12,8 +14,8 @@ require "active_hashcash/engine"
|
|
|
12
14
|
# before_action :check_hashcash, only: :create
|
|
13
15
|
# end
|
|
14
16
|
#
|
|
15
|
-
#
|
|
16
|
-
# For example, if your app runs behind a
|
|
17
|
+
# You are welcome to override most of the methods to customize to your needs.
|
|
18
|
+
# For example, if your app runs behind a load balancer you should probably override #hashcash_ip_address.
|
|
17
19
|
#
|
|
18
20
|
module ActiveHashcash
|
|
19
21
|
extend ActiveSupport::Concern
|
|
@@ -26,10 +28,41 @@ module ActiveHashcash
|
|
|
26
28
|
|
|
27
29
|
# This is base complexity.
|
|
28
30
|
# Consider lowering it to not exclude people with old and slow devices.
|
|
29
|
-
mattr_accessor :bits, instance_accessor: false, default:
|
|
31
|
+
mattr_accessor :bits, instance_accessor: false, default: 20
|
|
32
|
+
|
|
33
|
+
# Flexible complexity penalty rules applied to pushy IP addresses.
|
|
34
|
+
# Each rule must be a hash with:
|
|
35
|
+
# - :period => time window considered (e.g. 5.minutes, 1.hour, 1.day)
|
|
36
|
+
# - :rate => multiplier applied to the number of stamps in that window
|
|
37
|
+
# Assignment sorts rules from shortest to longest period.
|
|
38
|
+
#
|
|
39
|
+
# Example:
|
|
40
|
+
# ActiveHashcash.throttle_rules = [
|
|
41
|
+
# {period: 5.minutes, rate: 0.5},
|
|
42
|
+
# {period: 1.hour, rate: 0.34},
|
|
43
|
+
# {period: 1.day, rate: 0.25}
|
|
44
|
+
# ]
|
|
45
|
+
mattr_reader :throttle_rules, instance_accessor: false, default: [
|
|
46
|
+
{period: 1.hour, rate: 0.5},
|
|
47
|
+
{period: 24.hours, rate: 0.25}
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
def self.throttle_rules=(rules)
|
|
51
|
+
rules&.each do |rule|
|
|
52
|
+
raise ArgumentError, "ActiveHashcash.throttle_rules period must be present" if rule[:period].nil?
|
|
53
|
+
raise ArgumentError, "ActiveHashcash.throttle_rules rate must be >= 0" if rule[:rate].to_f.negative?
|
|
54
|
+
end
|
|
55
|
+
@@throttle_rules = rules && rules.sort_by { |rule| rule[:period] }
|
|
56
|
+
end
|
|
30
57
|
|
|
31
58
|
mattr_accessor :date_format, instance_accessor: false, default: "%y%m%d"
|
|
32
59
|
|
|
60
|
+
# Base controller class used by ActiveHashcash helpers/integration.
|
|
61
|
+
# Override this if your application subclasses the default Rails
|
|
62
|
+
# `ActionController::Base` (e.g. to apply common behavior across controllers).
|
|
63
|
+
|
|
64
|
+
# By default ActiveHashcash extends `ActionController::Base`, but you can change it to any controller,
|
|
65
|
+
# such as `AdminController` to handle authentication for the dashboard.
|
|
33
66
|
mattr_accessor :base_controller_class, default: "ActionController::Base"
|
|
34
67
|
|
|
35
68
|
# Call that method via a before_action when the form is submitted:
|
|
@@ -58,7 +91,7 @@ module ActiveHashcash
|
|
|
58
91
|
request.remote_ip
|
|
59
92
|
end
|
|
60
93
|
|
|
61
|
-
# Return current request path to be saved to the
|
|
94
|
+
# Return current request path to be saved to the successful ActiveHashcash::Stamp.
|
|
62
95
|
# If multiple forms are protected via hashcash this is an interesting info.
|
|
63
96
|
def hashcash_request_path
|
|
64
97
|
request.path
|
|
@@ -71,20 +104,29 @@ module ActiveHashcash
|
|
|
71
104
|
|
|
72
105
|
# This is the resource used to build the hashcash stamp.
|
|
73
106
|
# By default the host name is returned.
|
|
74
|
-
# It
|
|
107
|
+
# It should be good for most cases and prevent from reusing the same stamp between sites.
|
|
75
108
|
def hashcash_resource
|
|
76
109
|
ActiveHashcash.resource || request.host
|
|
77
110
|
end
|
|
78
111
|
|
|
79
112
|
# Returns the complexity, the higher the slower it is.
|
|
80
|
-
#
|
|
81
|
-
# The minimun value returned is ActiveHashcash.bits.
|
|
113
|
+
# Eventually adds penalties for bad and pushy IPs.
|
|
82
114
|
def hashcash_bits
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
115
|
+
(ActiveHashcash.bits + hashcash_throttle_penalty + hashcash_reputation_penalty).floor
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Compute a penalty for pushy IPs.
|
|
119
|
+
# The penalty rules can be defined with `ActiveHashcash.throttle_rules`.
|
|
120
|
+
def hashcash_throttle_penalty
|
|
121
|
+
rules = ActiveHashcash.throttle_rules || []
|
|
122
|
+
periods = rules.map { |rule| rule[:period] }
|
|
123
|
+
counts = ActiveHashcash::Stamp.where(ip_address: hashcash_ip_address).sum_by_periods(periods)
|
|
124
|
+
rules.each_with_index.sum { |rule, index| counts[index].to_i * rule[:rate].to_f }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Compute a reputation penalty for the current IP.
|
|
128
|
+
def hashcash_reputation_penalty
|
|
129
|
+
Reputation.scores(hashcash_ip_address).values.sum * 4
|
|
88
130
|
end
|
|
89
131
|
|
|
90
132
|
# Override if you want to rename the hashcash param.
|
|
@@ -109,7 +151,7 @@ module ActiveHashcash
|
|
|
109
151
|
# Override me for your own needs.
|
|
110
152
|
end
|
|
111
153
|
|
|
112
|
-
# Call it inside the form that have to be protected and don't forget to initialize the JavaScript
|
|
154
|
+
# Call it inside the form that have to be protected and don't forget to initialize the JavaScript Hashcash.setup().
|
|
113
155
|
# Unless you need something really special, you should not need to override this method.
|
|
114
156
|
#
|
|
115
157
|
# <% form_for model do |form| %>
|
|
@@ -118,7 +160,12 @@ module ActiveHashcash
|
|
|
118
160
|
# <% end %>
|
|
119
161
|
#
|
|
120
162
|
def hashcash_hidden_field_tag(name = :hashcash)
|
|
121
|
-
options = {
|
|
163
|
+
options = {
|
|
164
|
+
resource: hashcash_resource,
|
|
165
|
+
bits: hashcash_bits,
|
|
166
|
+
waiting_message: hashcash_waiting_message,
|
|
167
|
+
date: Date.today.strftime("%y%m%d")
|
|
168
|
+
}
|
|
122
169
|
view_context.hidden_field_tag(name, "", "data-hashcash" => options.to_json)
|
|
123
170
|
end
|
|
124
171
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: active_hashcash
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alexis Bernard
|
|
@@ -31,6 +31,7 @@ executables: []
|
|
|
31
31
|
extensions: []
|
|
32
32
|
extra_rdoc_files: []
|
|
33
33
|
files:
|
|
34
|
+
- AGENTS.md
|
|
34
35
|
- CHANGELOG.md
|
|
35
36
|
- LICENSE.txt
|
|
36
37
|
- README.md
|
|
@@ -45,8 +46,17 @@ files:
|
|
|
45
46
|
- app/helpers/active_hashcash/application_helper.rb
|
|
46
47
|
- app/helpers/active_hashcash/stamps_helper.rb
|
|
47
48
|
- app/jobs/active_hashcash/application_job.rb
|
|
49
|
+
- app/jobs/active_hashcash/reputation/cleanup_job.rb
|
|
50
|
+
- app/jobs/active_hashcash/reputation/update_abuse_score_job.rb
|
|
51
|
+
- app/jobs/active_hashcash/reputation/update_all_scores_job.rb
|
|
52
|
+
- app/jobs/active_hashcash/reputation/update_anonymous_score_job.rb
|
|
53
|
+
- app/jobs/active_hashcash/reputation/update_attack_score_job.rb
|
|
54
|
+
- app/jobs/active_hashcash/reputation/update_score_job.rb
|
|
48
55
|
- app/mailers/active_hashcash/application_mailer.rb
|
|
49
56
|
- app/models/active_hashcash/application_record.rb
|
|
57
|
+
- app/models/active_hashcash/reputation.rb
|
|
58
|
+
- app/models/active_hashcash/reputation/ipv4_address.rb
|
|
59
|
+
- app/models/active_hashcash/reputation/ipv4_range.rb
|
|
50
60
|
- app/models/active_hashcash/stamp.rb
|
|
51
61
|
- app/views/active_hashcash/addresses/index.html.erb
|
|
52
62
|
- app/views/active_hashcash/assets/_logo.svg.erb
|
|
@@ -71,6 +81,7 @@ files:
|
|
|
71
81
|
- config/locales/pt.yml
|
|
72
82
|
- config/routes.rb
|
|
73
83
|
- db/migrate/20240215143453_create_active_hashcash_stamps.rb
|
|
84
|
+
- db/migrate/20260831130000_create_active_hashcash_reputation_ipv4s.rb
|
|
74
85
|
- lib/active_hashcash.rb
|
|
75
86
|
- lib/active_hashcash/engine.rb
|
|
76
87
|
- lib/active_hashcash/version.rb
|
|
@@ -96,7 +107,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
96
107
|
- !ruby/object:Gem::Version
|
|
97
108
|
version: '0'
|
|
98
109
|
requirements: []
|
|
99
|
-
rubygems_version:
|
|
110
|
+
rubygems_version: 4.0.10
|
|
100
111
|
specification_version: 4
|
|
101
112
|
summary: Protect Rails applications against bots and brute force attacks without annoying
|
|
102
113
|
humans.
|