shugoi 0.4.2 → 0.4.3

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: d67dd6c94281f37e1991aeee7619e56094b865d2a4af684b9a998f1900d9a402
4
- data.tar.gz: 17c98240abde4f8ad2429e276816e8fb77ae86732d3c6d4957cf8abab0b61622
3
+ metadata.gz: ae02598e982619825522554b0190f1a1272acad33f637a978d0e821eb0861c34
4
+ data.tar.gz: ec6dbe09ad713c8bbee63755b6b9bf3f048d201aed2ec1d104c15660b3f80da5
5
5
  SHA512:
6
- metadata.gz: 1f704301cd98d98da245d31de729da7bab4a2387e001b7cddd514a2d2a653f7d4022069548068b1d3b3c54d6ec5127fd8bf342d5d25d4f67aec22018629c0025
7
- data.tar.gz: f190ad50e6be8fefc96513cbf925aca508cfba94aea5117ce990a1ac1d6380ff78257a57f643140d7b1c769f6bb22ede6a4ad7f7fcf98ce97fc916f29985059b
6
+ metadata.gz: 50fdb144de09568d85efeacf3fabc166459ecc9c2b95004432493325310188a41ef8c2544dae2773317de34ce66b438f0d69a76cbca3be862038c969a6766c63
7
+ data.tar.gz: e8332144c9d514bf52f37df0d829db7a3bcfd5f7232a7899a82a80ac498ae705b33edda25d55f5959e285713b1037c10214aeeadc5c47174e2b6f4ee87dc3ae6
data/lib/shugoi/core.rb CHANGED
@@ -59,7 +59,7 @@ module Shugoi
59
59
  valid_proof = !proof.empty? && @pow.valid?(proof)
60
60
  # Re-audit (résidu #3) : un cookie __sg_ok valide (HMAC serveur, 30 j) saute le
61
61
  # pre-flight PoW. Posé APRÈS une première résolution réussie (middleware).
62
- valid_cookie = !ctx[:sg_ok].to_s.empty? && @pow.sg_ok_valid?(ctx[:sg_ok])
62
+ valid_cookie = !ctx[:sg_ok].to_s.empty? && @pow.sg_ok_valid?(ctx[:sg_ok], ctx[:ip].to_s, ua)
63
63
  # Round 16 (R2) : la preuve est SINGLE-USE (par IP) — un rejeu → 307.
64
64
  proof_fresh = valid_proof ? consume_proof(proof, ctx[:ip].to_s) : false
65
65
  unless valid_cookie || proof_fresh
@@ -128,7 +128,7 @@ module Shugoi
128
128
  js = <<~JS
129
129
  (function(){
130
130
  var P=new URLSearchParams(location.search);
131
- var salt=P.get('salt')||'', ts=P.get('ts')||'', diff=parseInt(P.get('diff')||'#{@config.pow_difficulty}',10), path=P.get('path')||'/';
131
+ var salt=P.get('salt')||'', ts=P.get('ts')||'', nonce=P.get('nonce')||'', diff=parseInt(P.get('diff')||'#{@config.pow_difficulty}',10), path=P.get('path')||'/';
132
132
  // Open redirect (audit #5) : un //evil.com (protocole-relatif) ou un backslash
133
133
  // détourneraient le location.replace ci-dessous vers un domaine externe.
134
134
  if(path.charAt(0)!=='/'||path.charAt(1)==='/'||path.indexOf('\\\\')>=0)path='/';
@@ -140,7 +140,7 @@ module Shugoi
140
140
  function step(){
141
141
  crypto.subtle.digest('SHA-256',enc.encode(salt+':'+n.toString(16))).then(function(buf){
142
142
  var h=Array.from(new Uint8Array(buf)).map(function(v){return v.toString(16).padStart(2,'0')}).join('');
143
- if(bits(h)>=diff){var base=path;var q=(base.indexOf('?')>=0?'&':'?')+'sg_proof='+ts+':'+n.toString(16);location.replace(base+q)}
143
+ if(bits(h)>=diff){var base=path;var q=(base.indexOf('?')>=0?'&':'?')+'sg_proof='+ts+':'+nonce+':'+n.toString(16);location.replace(base+q)}
144
144
  else{n++;if(n<300000)step()}
145
145
  }).catch(function(){location.reload()});
146
146
  }
@@ -174,12 +174,13 @@ module Shugoi
174
174
  # 307 vers le challenge : body = tableau ASCII seul (curl le voit tel quel).
175
175
  def pow_challenge_307(ctx)
176
176
  ts = Utils.now_sec
177
- salt = Utils.hmac_hex(@config.signing_secret, ts.to_s)
177
+ nonce = SecureRandom.hex(8)
178
+ salt = Utils.hmac_hex(@config.signing_secret, "#{ts}:#{nonce}")
178
179
  prefix = ctx[:forwarded_prefix].to_s
179
180
  prefix = "" if prefix == "/"
180
181
  prefix = prefix.sub(%r{/\z}, "") unless prefix.empty?
181
182
  path = safe_challenge_path(ctx[:path].to_s)
182
- chal_url = "#{prefix}/__sg_challenge?ts=#{ts}&salt=#{salt}&diff=#{@config.pow_difficulty}&path=#{URI.encode_www_form_component(prefix + path)}"
183
+ chal_url = "#{prefix}/__sg_challenge?ts=#{ts}&salt=#{salt}&nonce=#{nonce}&diff=#{@config.pow_difficulty}&path=#{URI.encode_www_form_component(prefix + path)}"
183
184
  Decision.new(status: 307, content_type: "text/plain", body: BLOCK_PAGE, headers: { "location" => chal_url })
184
185
  end
185
186
 
data/lib/shugoi/pow.rb CHANGED
@@ -1,11 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
4
+
3
5
  module Shugoi
4
- # Proof-of-work anti-curl + cookies HMAC — parité avec core.ts (module Node) :
5
- # salt = HMAC(secret, ts)
6
- # proof = "ts:nonce" où SHA256(salt:nonce) a >= POW_DIFFICULTY bits à zéro en tête.
7
- # __sg_ok : ts:HMAC(secret, "sg_ok:ts") — 30 jours, saute le pre-flight PoW
8
- # __sg_authorized: ts:HMAC(secret, "sg_authorized:ts") 120 s, protège les assets /assets/*
6
+ # Proof-of-work anti-curl + cookies HMAC — parité avec core.ts (module Node) :
7
+ # salt = HMAC(secret, ts + ':' + nonce) (nonce 64 bits ALEATOIRE par challenge)
8
+ # proof = "ts:nonce:solution" où SHA256(salt:solution) a >= POW_DIFFICULTY bits à zéro.
9
+ # __sg_ok : ts:ipBucket:uaFp:HMAC(secret, "sg_ok:ts:ipBucket:uaFp") — 30 jours,
10
+ # lié au bucket IP + empreinte UA (non rejouable depuis une autre IP),
11
+ # saute le pre-flight PoW.
12
+ # __sg_authorized: ts:HMAC(secret, "sg_authorized:ts") — 120 s, protège les assets /assets/*
9
13
  class Pow
10
14
  POW_OK_TTL_MS = 30 * 24 * 3600 * 1000
11
15
  AUTHORIZED_TTL_MS = 120_000
@@ -19,48 +23,53 @@ module Shugoi
19
23
  # Génère le challenge à injecter (window.__sg_pow).
20
24
  def challenge
21
25
  ts = Utils.now_sec
22
- { ts: ts, salt: salt(ts), difficulty: @difficulty }
26
+ { ts: ts, nonce: nonce, salt: salt(ts, nonce), difficulty: @difficulty }
23
27
  end
24
28
 
25
- # Vérifie un proof "ts:nonce" (fenêtre @ttl_ms, comptage de bits CORRIGÉ).
29
+ # Vérifie un proof "ts:nonce:solution" (fenêtre @ttl_ms, comptage de bits CORRIGÉ).
26
30
  def valid?(proof)
27
31
  return false if proof.to_s.empty? || @secret.empty?
28
- ts_str, solution = proof.to_s.split(":", 2)
29
- return false if ts_str.nil? || solution.nil?
32
+ ts_str, nonce, solution = proof.to_s.split(":", 3)
33
+ return false if ts_str.nil? || nonce.nil? || solution.nil?
34
+ return false unless nonce.match?(/\A[0-9a-f]{16}\z/)
30
35
 
31
36
  ts = ts_str.to_i
32
37
  return false if ts.zero?
33
38
  return false if (Utils.now_ms - ts * 1000).abs > @ttl_ms
34
39
 
35
- digest = Utils.sha256_hex("#{salt(ts_str)}:#{solution}")
40
+ digest = Utils.sha256_hex("#{salt(ts_str, nonce)}:#{solution}")
36
41
  Utils.leading_zero_bits(digest) >= @difficulty
37
42
  end
38
43
 
39
- # Valeur du cookie __sg_ok (HMAC serveur, 30 j).
40
- def sg_ok_value
44
+ # Valeur du cookie __sg_ok (HMAC serveur, 30 j) — lié au bucket IP + empreinte UA.
45
+ def sg_ok_value(ip = "", ua = "")
41
46
  ts = Utils.now_sec
42
- "#{ts}:#{Utils.hmac_hex(@secret, "sg_ok:#{ts}")}"
47
+ bucket = ip_bucket(ip)
48
+ fp = ua_fp(ua)
49
+ "#{ts}:#{bucket}:#{fp}:#{Utils.hmac_hex(@secret, "sg_ok:#{ts}:#{bucket}:#{fp}")}"
43
50
  end
44
51
 
45
- def sg_ok_valid?(cookie_val)
52
+ def sg_ok_valid?(cookie_val, ip = "", ua = "")
46
53
  return false if @secret.empty? || cookie_val.to_s.empty?
47
- ts_str, sig = cookie_val.to_s.split(":", 2)
48
- return false if ts_str.nil? || sig.nil?
54
+ ts_str, bucket, fp, sig = cookie_val.to_s.split(":", 4)
55
+ return false if ts_str.nil? || bucket.nil? || fp.nil? || sig.nil?
49
56
 
50
57
  ts = ts_str.to_i
51
58
  return false if ts.zero?
52
59
  return false if Utils.now_ms - ts * 1000 > POW_OK_TTL_MS
53
60
  return false if ts * 1000 > Utils.now_ms + 60_000
61
+ # Lier au bucket IP + UA courants : un cookie d'une autre IP/UA → invalide.
62
+ return false unless bucket == ip_bucket(ip) && fp == ua_fp(ua)
54
63
 
55
- Utils.secure_equals(sig, Utils.hmac_hex(@secret, "sg_ok:#{ts_str}"))
64
+ Utils.secure_equals(sig, Utils.hmac_hex(@secret, "sg_ok:#{ts_str}:#{bucket}:#{fp}"))
56
65
  end
57
66
 
58
67
  # String Set-Cookie pour __sg_ok (posé par le middleware après une preuve valide).
59
68
  # @return [String, nil] nil si la preuve n'est pas valide
60
- def sg_ok_cookie(proof)
69
+ def sg_ok_cookie(proof, ip = "", ua = "")
61
70
  return nil unless valid?(proof)
62
71
  secure = production? ? "; Secure" : ""
63
- "__sg_ok=#{sg_ok_value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=#{POW_OK_TTL_MS / 1000}#{secure}"
72
+ "__sg_ok=#{sg_ok_value(ip, ua)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=#{POW_OK_TTL_MS / 1000}#{secure}"
64
73
  end
65
74
 
66
75
  # Cookie __sg_authorized posé par handleRender après un render réussi (grant valide).
@@ -89,8 +98,33 @@ module Shugoi
89
98
 
90
99
  private
91
100
 
92
- def salt(ts)
93
- Utils.hmac_hex(@secret, ts.to_s)
101
+ # Nonce 64 bits ALEATOIRE par challenge (plus de sel déterministe par seconde →
102
+ # précomputation par lots impossible). Parité core.ts sgNonce().
103
+ def nonce
104
+ SecureRandom.hex(8)
105
+ end
106
+
107
+ def salt(ts, nonce = "")
108
+ Utils.hmac_hex(@secret, "#{ts}:#{nonce}")
109
+ end
110
+
111
+ # Bucket d'IP (sans ':' pour rester parseable dans le cookie). IPv4 → /24,
112
+ # IPv6 → 4 hextets. Parité core.ts ipBucket().
113
+ def ip_bucket(ip)
114
+ ip = ip.to_s
115
+ return "0" if ip.empty? || ip == "unknown"
116
+ if ip.include?(".")
117
+ m = ip.match(/\A(\d+\.\d+\.\d+)(?:\.\d+)?\z/)
118
+ return m ? m[1] : "0"
119
+ end
120
+ return "0" unless ip.include?(":")
121
+ segs = ip.split(":").reject(&:empty?)
122
+ segs.first(4).join(".").empty? ? "0" : segs.first(4).join(".")
123
+ end
124
+
125
+ # Empreinte UA (16 hex). Parité core.ts uaFp().
126
+ def ua_fp(ua)
127
+ Utils.sha256_hex(ua.to_s).slice(0, 16)
94
128
  end
95
129
 
96
130
  def production?
@@ -83,7 +83,7 @@ module Shugoi
83
83
 
84
84
  # PoW validé → pose le cookie __sg_ok (navigations suivantes sans challenge).
85
85
  if (proof = query["sg_proof"]) && !decision
86
- if (ok_cookie = @pow.sg_ok_cookie(proof))
86
+ if (ok_cookie = @pow.sg_ok_cookie(proof, ctx[:ip].to_s, ctx[:ua].to_s))
87
87
  resp_headers["set-cookie"] = ok_cookie
88
88
  end
89
89
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Shugoi
4
- VERSION = "0.4.2"
4
+ VERSION = "0.4.3"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: shugoi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.2
4
+ version: 0.4.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shugoi