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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a8e7f600a4c42efa28175ebb98d4ba2206295bb316b450f79167fae543a52f71
4
- data.tar.gz: a62096662ee76b1b31529c71660cae12d8c0c7b01033d2440490aef839d6ed3f
3
+ metadata.gz: 0c53df9a89fac1a14b89fb109520d7b2c35d1455b3b2624d9200186d9a295cf7
4
+ data.tar.gz: d090c36da4d4d17f80b5d7a653cbe2544fc311cdcf63a97bd3b46d1cf944631b
5
5
  SHA512:
6
- metadata.gz: 4fa81355c730a651547d30e0e115bbfc3bcc35623482efb85d324e6ecf826acec46dc7f3b47a8dce48264f8da227b487465d98ba964571b3670f244188653590
7
- data.tar.gz: 9aeb5f2fa5c053795402d30c286491f43034a173a26913e3ec81210bb6da95dbc11a547cba98fab1bff81fc22eb832e2984744df1bdc580482c8d882ba213926
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
- ### Before version 0.3.0
156
+ ## Complexity
157
157
 
158
- You must have Redis in order to prevent double spent stamps. Otherwise it will be useless.
159
- It automatically tries to connect with the environment variables `ACTIVE_HASHCASH_REDIS_URL` or `REDIS_URL`.
160
- You can also manually set the URL with `ActiveHashcash.redis_url = redis://user:password@localhost:6379`.
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
- You should call `ActiveHashcash::Store#clean` once a day, to remove expired stamps.
162
+ ### Penalties
163
163
 
164
- To upgrade from 0.2.0 you must run the migration :
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
- rails active_hashcash:install:migrations
168
- rails db:migrate
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
- ## Complexity
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
- Complexity is the most important parameter. By default its value is 20 and requires most of the time 5 to 20 seconds to be solved on a decent laptop.
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
- You can change the minimum complexity with `ActiveHashcash.bits = 20`.
212
+ The following lists are used to increase the complexity for bad IPs:
178
213
 
179
- Since version 0.3.0, the complexity increases with the number of stamps spent during le last 24H from the same IP address.
180
- Thus it becomes very efficient to slow down brute force attacks.
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 10 to 20 times slower than the official C version.
185
- I first used the SubtleCrypto API but it is surprisingly slower than a custom SHA1 implementation.
186
- Maybe I did in an inefficient way 2df3ba5?
187
- Another idea would be to compile the work algorithm in wasm.
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
- Unfortunately, I'm not a JavaScript expert.
190
- Maybe you have good JS skills to optimize it?
191
- Any help would be appreciate to better fights bots and brute for attacks!
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="hiden" name="hashcash" data-hashcash="{resource: 'site.example', bits: 16}"/>
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 || year + month + day,
100
+ options.date || Hashcash.formatToday(),
75
101
  resource,
76
- options.extension || Hashcash.default.extension,
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
- Hashcash.Stamp = function(version, bits, date, resource, extension, rand, counter = 0) {
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[4], args[5], args[6])
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, this.extension, this.rand, this.counter].join(":")
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
- * Secure Hash Algorithm (SHA1)
131
- * http://www.webtoolkit.info/
132
- **/
133
- Hashcash.sha1 = function(msg) {
134
- var rotate_left = Hashcash.sha1.rotate_left
135
- var Utf8Encode = Hashcash.sha1.Utf8Encode
136
-
137
- var blockstart;
138
- var i, j;
139
- var W = new Array(80);
140
- var H0 = 0x67452301;
141
- var H1 = 0xEFCDAB89;
142
- var H2 = 0x98BADCFE;
143
- var H3 = 0x10325476;
144
- var H4 = 0xC3D2E1F0;
145
- var A, B, C, D, E;
146
- var temp;
147
- msg = Utf8Encode(msg);
148
- var msg_len = msg.length;
149
- var word_array = new Array();
150
- for (i = 0; i < msg_len - 3; i += 4) {
151
- j = msg.charCodeAt(i) << 24 | msg.charCodeAt(i + 1) << 16 |
152
- msg.charCodeAt(i + 2) << 8 | msg.charCodeAt(i + 3);
153
- word_array.push(j);
154
- }
155
- switch (msg_len % 4) {
156
- case 0:
157
- i = 0x080000000;
158
- break;
159
- case 1:
160
- i = msg.charCodeAt(msg_len - 1) << 24 | 0x0800000;
161
- break;
162
- case 2:
163
- i = msg.charCodeAt(msg_len - 2) << 24 | msg.charCodeAt(msg_len - 1) << 16 | 0x08000;
164
- break;
165
- case 3:
166
- i = msg.charCodeAt(msg_len - 3) << 24 | msg.charCodeAt(msg_len - 2) << 16 | msg.charCodeAt(msg_len - 1) << 8 | 0x80;
167
- break;
168
- }
169
- word_array.push(i);
170
- while ((word_array.length % 16) != 14) word_array.push(0);
171
- word_array.push(msg_len >>> 29);
172
- word_array.push((msg_len << 3) & 0x0ffffffff);
173
- for (blockstart = 0; blockstart < word_array.length; blockstart += 16) {
174
- for (i = 0; i < 16; i++) W[i] = word_array[blockstart + i];
175
- for (i = 16; i <= 79; i++) W[i] = rotate_left(W[i - 3] ^ W[i - 8] ^ W[i - 14] ^ W[i - 16], 1);
176
- A = H0;
177
- B = H1;
178
- C = H2;
179
- D = H3;
180
- E = H4;
181
- for (i = 0; i <= 19; i++) {
182
- temp = (rotate_left(A, 5) + ((B & C) | (~B & D)) + E + W[i] + 0x5A827999) & 0x0ffffffff;
183
- E = D;
184
- D = C;
185
- C = rotate_left(B, 30);
186
- B = A;
187
- A = temp;
188
- }
189
- for (i = 20; i <= 39; i++) {
190
- temp = (rotate_left(A, 5) + (B ^ C ^ D) + E + W[i] + 0x6ED9EBA1) & 0x0ffffffff;
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
- for (i = 40; i <= 59; i++) {
198
- temp = (rotate_left(A, 5) + ((B & C) | (B & D) | (C & D)) + E + W[i] + 0x8F1BBCDC) & 0x0ffffffff;
199
- E = D;
200
- D = C;
201
- C = rotate_left(B, 30);
202
- B = A;
203
- A = temp;
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
- for (i = 60; i <= 79; i++) {
206
- temp = (rotate_left(A, 5) + (B ^ C ^ D) + E + W[i] + 0xCA62C1D6) & 0x0ffffffff;
207
- E = D;
208
- D = C;
209
- C = rotate_left(B, 30);
210
- B = A;
211
- A = temp;
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
- Hashcash.hexSha1 = function(msg) {
223
- var array = Hashcash.sha1(msg)
224
- var cvt_hex = Hashcash.sha1.cvt_hex
225
- return cvt_hex(array[0]) + cvt_hex(array[1]) + cvt_hex(array[2]) + cvt_hex(array3) + cvt_hex(array[4])
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
- Hashcash.sha1.rotate_left = function(n, s) {
229
- var t4 = (n << s) | (n >>> (32 - s));
230
- return t4;
231
- };
232
-
233
- Hashcash.sha1.lsb_hex = function(val) {
234
- var str = '';
235
- var i;
236
- var vh;
237
- var vl;
238
- for (i = 0; i <= 6; i += 2) {
239
- vh = (val >>> (i * 4 + 4)) & 0x0f;
240
- vl = (val >>> (i * 4)) & 0x0f;
241
- str += vh.toString(16) + vl.toString(16);
242
- }
243
- return str;
244
- };
245
-
246
- Hashcash.sha1.cvt_hex = function(val) {
247
- var str = '';
248
- var i;
249
- var v;
250
- for (i = 7; i >= 0; i--) {
251
- v = (val >>> (i * 4)) & 0x0f;
252
- str += v.toString(16);
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
- return str;
255
- };
256
-
257
- Hashcash.sha1.Utf8Encode = function(string) {
258
- string = string.replace(/\r\n/g, '\n');
259
- var utftext = '';
260
- for (var n = 0; n < string.length; n++) {
261
- var c = string.charCodeAt(n);
262
- if (c < 128) {
263
- utftext += String.fromCharCode(c);
264
- } else if ((c > 127) && (c < 2048)) {
265
- utftext += String.fromCharCode((c >> 6) | 192);
266
- utftext += String.fromCharCode((c & 63) | 128);
267
- } else {
268
- utftext += String.fromCharCode((c >> 12) | 224);
269
- utftext += String.fromCharCode(((c >> 6) & 63) | 128);
270
- utftext += String.fromCharCode((c & 63) | 128);
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
- return utftext;
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,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveHashcash
4
+ module Reputation
5
+ class CleanupJob < ApplicationJob
6
+ def perform
7
+ IPv4Address.delete_zero_scores
8
+ IPv4Range.delete_zero_scores
9
+ end
10
+ end
11
+ end
12
+ end
@@ -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
- # Pare and instanciate a stamp from a sting which respects the hashcash format:
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
- Digest::SHA1.hexdigest(to_s).hex >> (160-bits) == 0
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
@@ -1,3 +1,3 @@
1
1
  module ActiveHashcash
2
- VERSION = "0.4.0"
2
+ VERSION = "0.6.0"
3
3
  end
@@ -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
- # Your are welcome to override most of the methods to customize to your needs.
16
- # For example, if your app runs behind a loab balancer you should probably override #hashcash_ip_address.
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: 16
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 sucessful ActiveHash::Stamp.
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' should be good for most cases and prevent from reusing the same stamp between sites.
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
- # Complexity is increased logarithmicly for each IP during the last 24H to slowdown brute force attacks.
81
- # The minimun value returned is ActiveHashcash.bits.
113
+ # Eventually adds penalties for bad and pushy IPs.
82
114
  def hashcash_bits
83
- if (previous_stamp_count = ActiveHashcash::Stamp.where(ip_address: hashcash_ip_address).where(created_at: 1.day.ago..).count) > 0
84
- (ActiveHashcash.bits + Math.log2(previous_stamp_count)).floor
85
- else
86
- ActiveHashcash.bits
87
- end
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 Hascash.setup().
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 = {resource: hashcash_resource, bits: hashcash_bits, waiting_message: hashcash_waiting_message}
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.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: 3.6.7
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.