spamtrap 0.3.4 → 0.4.2

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: f1f9fa53d08c869e9952416e63376c31ed74f74584e9cf1e59f7439364c370d5
4
- data.tar.gz: 4ecae5a00af343aa3c8856a3f2bcf04999bd37a0112d067d3d907e5b85b75e2a
3
+ metadata.gz: a31cf3397317c7e4fadc471f44ce713dac53bea0701ba50b24a8259a77ed4b58
4
+ data.tar.gz: 3c3f3f0119ae22e79a3e327ae5aa1c65b915b906c2bb8b5e3621e536d62ccab2
5
5
  SHA512:
6
- metadata.gz: 825936fb38cb3095ecf504c42aff353081a8352539a535beff67d85ef2423fc4e7bac328c107d48d5d0ed092ea1b508be6bb06e7490c33cceb2f230a0c89e45a
7
- data.tar.gz: c35648761da54aab75b8b8d587f24d4ccfbfb03210d23ed930fbdbea2743876de9a795d19980acac9bac45b92e7bbe94621f8419440031d2e15412b6876e041a
6
+ metadata.gz: 9abc563f9854fb5759371ac2973b3071b3cc86c4cab3af2566aed9b491079a2870b1df82e1dd5cf561f107a88786a818c42d294678e454cce86522cdabfe4ae0
7
+ data.tar.gz: 6d4b395ebb6d81a2a002919935001a77a7febdf3353fa5556de2ccd29883ecfe5f29e21b63de448ff53ccfb0233b85708547892cd089123a843775a9aace449d
@@ -0,0 +1,15 @@
1
+ require 'rails/generators'
2
+
3
+ module Spamtrap
4
+ module Generators
5
+ class InstallGenerator < Rails::Generators::Base
6
+ source_root File.expand_path('templates', __dir__)
7
+
8
+ desc 'Creates a Spamtrap initializer documenting every configuration option and its default.'
9
+
10
+ def create_initializer
11
+ template 'spamtrap.rb', 'config/initializers/spamtrap.rb'
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,75 @@
1
+ # Spamtrap global configuration. Every option can be overridden per controller action or
2
+ # per render; an explicit value there always wins, including `false` over a global `true`.
3
+ # See https://github.com/cedric/spamtrap/ for the full guide.
4
+
5
+ # Honeypot
6
+
7
+ # Decoys f.spamtrap renders: any of :textarea, :text, :checkbox.
8
+ # Spamtrap.honeypot_styles = [:textarea]
9
+
10
+ # Require a hidden field that only an executing script fills in; excludes no-JS users.
11
+ # Spamtrap.js_proof = false
12
+
13
+ # Nonce
14
+
15
+ # false, true, or :single_use.
16
+ # Spamtrap.nonce = false
17
+
18
+ # Seconds; also caps mutation token age unless mutation_timeout is set.
19
+ # Spamtrap.nonce_timeout = 1800
20
+
21
+ # Seconds a submitted timestamp may sit in the future.
22
+ # Spamtrap.nonce_skew = 60
23
+
24
+ # true (full IP), :prefix (/24 IPv4 or /48 IPv6), or false (not bound).
25
+ # Spamtrap.nonce_bind_ip = true
26
+
27
+ # Where nonce: :single_use records seen nonce ids; defaults to the app's cache.
28
+ # Spamtrap.nonce_store = Rails.cache
29
+
30
+ # Mutation
31
+
32
+ # false, true/:strict (traps plaintext keys), or :lenient (remap only).
33
+ # Spamtrap.mutate = false
34
+
35
+ # Seconds; defaults to nonce_timeout.
36
+ # Spamtrap.mutation_timeout = nil
37
+
38
+ # Extra top-level param names a strict action accepts unencrypted.
39
+ # Spamtrap.allowed_params = []
40
+
41
+ # Whether mutated fields keep an id/for derived from the real field name.
42
+ # Spamtrap.stable_ids = true
43
+
44
+ # Fill time
45
+
46
+ # Minimum seconds between render and submission a human needs; false or 0 disables.
47
+ # Spamtrap.min_fill_time = 1
48
+
49
+ # Responses and callbacks
50
+
51
+ # How a trapped request responds: :head, :no_content, :unprocessable, :redirect_back,
52
+ # a callable, or a Hash keyed by trap reason.
53
+ # Spamtrap.trap_response = :head
54
+
55
+ # Optional callback invoked whenever the spamtrap fires.
56
+ # Spamtrap.on_trap = ->(reason:, request:) { }
57
+
58
+ # App-defined heuristic run after every other check; truthy result traps as :content.
59
+ # Spamtrap.suspicious_if = nil
60
+
61
+ # Keys
62
+
63
+ # The current HKDF source secret; defaults to the app's own so no setup is needed to opt in.
64
+ # Spamtrap.secret_key_base = nil
65
+
66
+ # Set during a rotation window so tokens minted under the old secret still verify/decrypt.
67
+ # Spamtrap.previous_secret_key_base = nil
68
+
69
+ # Binds tokens to a value derived from the request (e.g. host), for multi-tenant apps; off by default.
70
+ # Spamtrap.token_context = nil
71
+
72
+ # Testing
73
+
74
+ # Global kill switch; false skips every spamtrap check. Handy for test environments.
75
+ # Spamtrap.enabled = true
@@ -1,6 +1,16 @@
1
1
  module Spamtrap::Controller
2
2
  include Spamtrap::Crypto
3
3
 
4
+ # Fields Rails/browsers/forms add that a strict action must not treat as attacker-controlled plaintext.
5
+ FRAMEWORK_PARAMS = %w[
6
+ authenticity_token commit button _method utf8
7
+ spamtrap_timestamp spamtrap_nonce spamtrap_nonce_id spamtrap_js
8
+ ].freeze
9
+
10
+ # Captcha widgets inject their own plaintext param the app can't encrypt: Google
11
+ # reCAPTCHA v2/v3, hCaptcha, and Cloudflare Turnstile.
12
+ CAPTCHA_PARAMS = %w[g-recaptcha-response h-captcha-response cf-turnstile-response].freeze
13
+
4
14
  def self.included(base)
5
15
  base.extend ActsAsMethods
6
16
  end
@@ -9,79 +19,263 @@ module Spamtrap::Controller
9
19
  def spamtrap(honeypot = 'spamtrap', options = {}, &block)
10
20
  # Capture explicit per-call values; use sentinel so globals are read
11
21
  # at request time rather than at class definition time.
12
- nonce_opt = options.key?(:nonce) ? options.delete(:nonce) : :global
13
- timeout_opt = options.key?(:nonce_timeout) ? options.delete(:nonce_timeout) : :global
14
- mutate_opt = options.key?(:mutate) ? options.delete(:mutate) : :global
15
- on_trap_opt = options.key?(:on_trap) ? options.delete(:on_trap) : :global
22
+ nonce_opt = options.key?(:nonce) ? options.delete(:nonce) : :global
23
+ timeout_opt = options.key?(:nonce_timeout) ? options.delete(:nonce_timeout) : :global
24
+ mutate_opt = options.key?(:mutate) ? options.delete(:mutate) : :global
25
+ on_trap_opt = options.key?(:on_trap) ? options.delete(:on_trap) : :global
26
+ trap_response_opt = options.key?(:trap_response) ? options.delete(:trap_response) : :global
27
+ nonce_bind_ip_opt = options.key?(:nonce_bind_ip) ? options.delete(:nonce_bind_ip) : :global
28
+ min_fill_time_opt = options.key?(:min_fill_time) ? options.delete(:min_fill_time) : :global
29
+ js_proof_opt = options.key?(:js_proof) ? options.delete(:js_proof) : :global
30
+ suspicious_if_opt = options.key?(:suspicious_if) ? options.delete(:suspicious_if) : :global
16
31
 
17
32
  before_action(options) do |controller|
33
+ next unless Spamtrap.enabled
34
+
18
35
  controller.instance_eval(&block) if block_given?
19
36
  controller.instance_eval do
20
- nonce_enabled = nonce_opt == :global ? Spamtrap.nonce : nonce_opt
21
- nonce_timeout = timeout_opt == :global ? Spamtrap.nonce_timeout : timeout_opt
22
- mutate_enabled = mutate_opt == :global ? Spamtrap.mutate : mutate_opt
23
-
24
- spamtrap_remap_params if mutate_enabled
25
-
26
- if params[honeypot].present?
27
- Rails.logger.warn "Spamtrap triggered by #{request.remote_ip}."
28
- spamtrap_invoke_on_trap(:honeypot, on_trap_opt)
29
- head 200
30
- elsif nonce_enabled && !spamtrap_valid_nonce?(nonce_timeout)
31
- Rails.logger.warn "Spamtrap nonce invalid from #{request.remote_ip}."
32
- spamtrap_invoke_on_trap(:nonce, on_trap_opt)
33
- head 200
37
+ nonce_enabled = nonce_opt == :global ? Spamtrap.nonce : nonce_opt
38
+ nonce_timeout = timeout_opt == :global ? Spamtrap.nonce_timeout : timeout_opt
39
+ mutate_enabled = mutate_opt == :global ? Spamtrap.mutate : mutate_opt
40
+ bind_ip = nonce_bind_ip_opt == :global ? Spamtrap.nonce_bind_ip : nonce_bind_ip_opt
41
+ min_fill_time = min_fill_time_opt == :global ? Spamtrap.min_fill_time : min_fill_time_opt
42
+ js_proof_enabled = js_proof_opt == :global ? Spamtrap.js_proof : js_proof_opt
43
+ suspicious_hook = suspicious_if_opt == :global ? Spamtrap.suspicious_if : suspicious_if_opt
44
+ # true and :strict both trap plaintext keys; :lenient only remaps.
45
+ strict = mutate_enabled && mutate_enabled != :lenient
46
+ context = Spamtrap.token_context_for(request)
47
+
48
+ remap = spamtrap_remap_params(Spamtrap.mutation_timeout, context) if mutate_enabled
49
+
50
+ if Spamtrap.honeypot_fields(honeypot).each_value.any? { |field| params[field].present? }
51
+ spamtrap_trap(:honeypot, honeypot, on_trap_opt, trap_response_opt)
52
+ # :expired traps in lenient mode too, or a remapped-but-stale token would otherwise be accepted.
53
+ elsif mutate_enabled && remap[0] == :expired
54
+ spamtrap_trap(:mutation_expired, honeypot, on_trap_opt, trap_response_opt)
55
+ elsif strict && (reason = spamtrap_strict_violation(remap, honeypot))
56
+ spamtrap_trap(reason, honeypot, on_trap_opt, trap_response_opt)
57
+ elsif nonce_enabled && (reason = spamtrap_nonce_failure(nonce_timeout, honeypot, nonce_enabled, bind_ip: bind_ip, min_fill_time: min_fill_time, context: context))
58
+ spamtrap_trap(reason, honeypot, on_trap_opt, trap_response_opt)
59
+ # Mutation-only forms: the nonce path above already applied the fill time.
60
+ elsif !nonce_enabled && mutate_enabled && spamtrap_too_fast?(min_fill_time)
61
+ spamtrap_trap(:too_fast, honeypot, on_trap_opt, trap_response_opt)
62
+ elsif js_proof_enabled && (reason = spamtrap_js_proof_failure(honeypot, context: context))
63
+ spamtrap_trap(reason, honeypot, on_trap_opt, trap_response_opt)
64
+ elsif suspicious_hook && spamtrap_content_suspicious?(suspicious_hook)
65
+ spamtrap_trap(:content, honeypot, on_trap_opt, trap_response_opt)
34
66
  end
35
67
  end
36
68
  end
37
69
  end
38
70
  end
39
71
 
40
- def spamtrap_valid_nonce?(timeout)
72
+ def spamtrap_nonce_failure(timeout, honeypot, mode, bind_ip: Spamtrap.nonce_bind_ip, min_fill_time: Spamtrap.min_fill_time, context: nil)
41
73
  timestamp = params[:spamtrap_timestamp].to_i
42
- nonce = params[:spamtrap_nonce].to_s
43
- return false if timestamp.zero? || nonce.empty?
44
- return false if Time.now.to_i - timestamp > timeout.to_i
74
+ nonce = params[:spamtrap_nonce].to_s
75
+ nonce_id = params[:spamtrap_nonce_id].to_s
76
+ return :nonce_missing if timestamp.zero? || nonce.blank? || nonce_id.blank?
45
77
 
46
- secret = Rails.application.secret_key_base
47
- expected = OpenSSL::HMAC.hexdigest('SHA256', secret, "#{timestamp}:#{request.remote_ip}")
48
- ActiveSupport::SecurityUtils.secure_compare(nonce, expected)
49
- end
78
+ now = Time.now.to_i
79
+ # nonce_id doubles as a cache key under single-use mode, so bound its shape.
80
+ return :nonce_invalid unless nonce_id.match?(/\A[0-9a-f]{32}\z/)
81
+ return :nonce_invalid if timestamp - now > Spamtrap.nonce_skew
82
+ return :nonce_expired if now - timestamp > timeout.to_i # checked before the HMAC so a genuine expired token reports as expired
50
83
 
51
- def spamtrap_remap_params
52
- salt_hex = params[:spamtrap_mutation_salt].to_s
53
- return if salt_hex.empty?
84
+ expected = spamtrap_nonce_digest(timestamp, request.remote_ip, honeypot, nonce_id, bind_ip: bind_ip, context: context)
85
+ unless ActiveSupport::SecurityUtils.secure_compare(nonce, expected)
86
+ # Retry with the previous secret during a rotation window before giving up.
87
+ previous_secret = Spamtrap.previous_secret_key_base
88
+ previous = previous_secret && spamtrap_nonce_digest(timestamp, request.remote_ip, honeypot, nonce_id, bind_ip: bind_ip, secret: previous_secret, context: context)
89
+ return :nonce_invalid unless previous && ActiveSupport::SecurityUtils.secure_compare(nonce, previous)
90
+ end
54
91
 
55
- salt_bytes = [salt_hex].pack('H*')
56
- return unless salt_bytes.bytesize == Spamtrap::Crypto::NONCE_LEN
92
+ # After the HMAC (so a forged timestamp is :nonce_invalid) but before single-use records
93
+ # the id, or a human trapped as too fast could never resubmit the same page.
94
+ return :too_fast if spamtrap_too_fast?(min_fill_time)
95
+
96
+ if mode == :single_use
97
+ key = "spamtrap:nonce:#{nonce_id}"
98
+ stored = Spamtrap.nonce_store.write(key, 1, unless_exist: true, expires_in: timeout.to_i + Spamtrap.nonce_skew)
99
+ if Spamtrap.nonce_store.is_a?(ActiveSupport::Cache::NullStore)
100
+ Spamtrap::Controller.warn_null_store_once
101
+ elsif !stored
102
+ return :nonce_replayed
103
+ end
104
+ end
57
105
 
58
- spamtrap_remap_hash(params, salt_bytes)
59
- rescue ArgumentError
60
106
  nil
61
107
  end
62
108
 
63
- def spamtrap_remap_hash(hash, salt)
109
+ # Warned once per process: a NullStore accepts every write, so it can never catch a replay.
110
+ def self.warn_null_store_once
111
+ return if @null_store_warned
112
+ @null_store_warned = true
113
+ Rails.logger.warn 'Spamtrap: nonce: :single_use needs a real Spamtrap.nonce_store; NullStore cannot detect replays.'
114
+ end
115
+
116
+ def spamtrap_too_fast?(min_fill_time)
117
+ return false unless min_fill_time && min_fill_time.to_i.positive?
118
+
119
+ ts = params[:spamtrap_timestamp].to_i
120
+ ts.positive? && Time.now.to_i - ts < min_fill_time.to_i
121
+ end
122
+
123
+ # Returns [:missing, []], [:expired, leftovers], or [:ok, leftovers], where leftovers is
124
+ # an array of [key, depth] for every submitted key that did not decrypt (depth 0 == top
125
+ # level of params), letting spamtrap_strict_violation tell :missing apart from a real
126
+ # plaintext violation. :expired still remaps every key (no additional staleness bound on
127
+ # the decrypt itself) so on_trap/trap_response can re-render the form with the user's
128
+ # input; the action never runs, so an old token can only help show that input back, and
129
+ # key rotation bounds how far back it can reach.
130
+ def spamtrap_remap_params(timeout, context = nil)
131
+ ts = params[:spamtrap_timestamp].to_i
132
+ return [:missing, []] if ts.zero?
133
+
134
+ now = Time.now.to_i
135
+ status = now - ts > timeout.to_i || ts - now > Spamtrap.nonce_skew ? :expired : :ok
136
+ [status, spamtrap_remap_hash(params, spamtrap_aad(ts, context))]
137
+ end
138
+
139
+ def spamtrap_remap_hash(hash, aad, depth = 0)
140
+ leftovers = []
64
141
  hash.each_key.to_a.each do |key|
65
- real = spamtrap_decrypt_field(key.to_s, salt)
142
+ key_str = key.to_s
143
+ # Rails date/time selects post <token>(1i)/(2i)/(3i); decrypt the base, keep the suffix.
144
+ base, suffix = key_str.match(/\A(.+?)(\(\d+[a-z]\))\z/)&.captures
145
+ real = spamtrap_decrypt_field(base || key_str, aad)
66
146
  if real
67
- hash[real] = hash.delete(key)
68
- key = real
147
+ renamed = suffix ? "#{real}#{suffix}" : real
148
+ hash[renamed] = hash.delete(key)
149
+ key = renamed
69
150
  end
70
151
  child = hash[key]
71
- spamtrap_remap_hash(child, salt) if child.is_a?(ActionController::Parameters)
152
+ # A nesting container (object name, or array of them) is structure, not a submitted
153
+ # field name, so it's never itself flagged even when its own key didn't decrypt.
154
+ container = child.is_a?(ActionController::Parameters) ||
155
+ (child.is_a?(Array) && child.any? { |el| el.is_a?(ActionController::Parameters) })
156
+ leftovers << [key.to_s, depth] if real.nil? && !container
157
+ case child
158
+ when ActionController::Parameters
159
+ leftovers.concat(spamtrap_remap_hash(child, aad, depth + 1))
160
+ when Array
161
+ child.each { |el| leftovers.concat(spamtrap_remap_hash(el, aad, depth + 1)) if el.is_a?(ActionController::Parameters) }
162
+ end
72
163
  end
164
+ leftovers
165
+ end
166
+
167
+ # nil means no violation. :mutation_expired means no timestamp was submitted at all, so
168
+ # blaming a specific field with :plaintext_field would mislead (the :expired case is
169
+ # already handled by the caller before this runs). Otherwise: any leftover below the top
170
+ # level always traps (plaintext smuggled into an otherwise-encrypted nested object); a
171
+ # top-level leftover only traps if it isn't allowlisted.
172
+ def spamtrap_strict_violation(remap, honeypot)
173
+ status, leftovers = remap
174
+ return :mutation_expired if status == :missing
175
+ return :plaintext_field if leftovers.any? { |_key, depth| depth > 0 }
176
+
177
+ # controller/action/id/format are Rails' own routing params, always present and always plaintext.
178
+ allowed = FRAMEWORK_PARAMS + CAPTCHA_PARAMS + Spamtrap.honeypot_fields(honeypot).values +
179
+ request.path_parameters.keys.map(&:to_s) + Spamtrap.allowed_params
180
+ :plaintext_field if leftovers.any? { |key, _depth| !allowed.include?(key) }
73
181
  end
74
182
 
75
- def spamtrap_invoke_on_trap(reason, on_trap_opt)
183
+ # ts.zero? means no timestamp was submitted at all, so there's nothing to bind spamtrap_js to.
184
+ # The token is public in the page source, so without nonce or mutation the timestamp is the
185
+ # only thing that expires it; bound its age like a nonce.
186
+ def spamtrap_js_proof_failure(honeypot, context: nil)
187
+ ts = params[:spamtrap_timestamp].to_i
188
+ js = params[:spamtrap_js].to_s
189
+ return :no_js if ts.zero? || js.blank?
190
+
191
+ now = Time.now.to_i
192
+ return :no_js if now - ts > Spamtrap.nonce_timeout.to_i || ts - now > Spamtrap.nonce_skew
193
+
194
+ expected = spamtrap_js_digest(ts, honeypot, context: context)
195
+ return nil if ActiveSupport::SecurityUtils.secure_compare(js, expected)
196
+
197
+ # Retry with the previous secret during a rotation window before giving up.
198
+ previous_secret = Spamtrap.previous_secret_key_base
199
+ previous = previous_secret && spamtrap_js_digest(ts, honeypot, secret: previous_secret, context: context)
200
+ return nil if previous && ActiveSupport::SecurityUtils.secure_compare(js, previous)
201
+
202
+ :no_js
203
+ end
204
+
205
+ # A raising hook is treated as not suspicious (fail open); a bug in an app's own
206
+ # keyword regex must not end up trapping every visitor.
207
+ def spamtrap_content_suspicious?(callable)
208
+ !!spamtrap_call(callable, { params: params, request: request, controller: self }, positional: params)
209
+ rescue StandardError => e
210
+ Rails.logger.error "Spamtrap suspicious_if callback raised: #{e.class}: #{e.message}"
211
+ false
212
+ end
213
+
214
+ # Logs, invokes on_trap, then applies the trap response unless the callback already
215
+ # rendered/redirected (performed? short-circuits so a custom on_trap response wins).
216
+ def spamtrap_trap(reason, honeypot, on_trap_opt, trap_response_opt)
217
+ Rails.logger.warn "Spamtrap #{reason} from #{request.remote_ip}."
218
+ payload = { reason: reason, honeypot: honeypot.to_s, controller: controller_path, action: action_name, ip: request.remote_ip, request: request }
219
+ ActiveSupport::Notifications.instrument('trap.spamtrap', payload) { }
220
+ spamtrap_invoke_on_trap(reason, on_trap_opt, honeypot)
221
+ return if performed?
222
+
223
+ setting = trap_response_opt == :global ? Spamtrap.trap_response : trap_response_opt
224
+ spamtrap_render_trap(reason, setting)
225
+ end
226
+
227
+ # setting: a Hash keyed by reason (falls back to :default, then :head), a callable
228
+ # (controller, reason), or one of :head/:no_content/:unprocessable/:redirect_back.
229
+ def spamtrap_render_trap(reason, setting)
230
+ if setting.is_a?(Hash)
231
+ spamtrap_render_trap(reason, setting[reason] || setting[:default] || :head)
232
+ elsif setting.respond_to?(:call)
233
+ setting.call(self, reason)
234
+ head 200 unless performed? # callable didn't render/redirect, so fall back
235
+ elsif setting == :head
236
+ head 200
237
+ elsif setting == :no_content
238
+ head 204
239
+ elsif setting == :unprocessable
240
+ head 422
241
+ elsif setting == :redirect_back
242
+ redirect_back_or_to('/', status: :see_other)
243
+ else
244
+ raise ArgumentError, "Unknown Spamtrap.trap_response: #{setting.inspect}"
245
+ end
246
+ end
247
+
248
+ def spamtrap_invoke_on_trap(reason, on_trap_opt, honeypot)
76
249
  callback = on_trap_opt == :global ? Spamtrap.on_trap : on_trap_opt
77
250
  return unless callback.respond_to?(:call)
78
- callback.call(reason: reason, request: request)
251
+
252
+ payload = { reason: reason, request: request, controller: self, honeypot: honeypot.to_s, params: params }
253
+ spamtrap_call(callback, payload, positional: payload)
79
254
  rescue StandardError => e
80
255
  Rails.logger.error "Spamtrap on_trap callback raised: #{e.class}: #{e.message}"
81
256
  end
82
257
 
83
- private :spamtrap_valid_nonce?, :spamtrap_remap_params, :spamtrap_remap_hash, :spamtrap_invoke_on_trap
258
+ # Dispatches a user-supplied callable per its declared parameters, shared by on_trap and
259
+ # suspicious_if: kwrest gets the whole payload, keywords get a matching slice, a bare
260
+ # positional gets `positional` (the payload for on_trap, params for suspicious_if).
261
+ def spamtrap_call(callable, payload, positional:)
262
+ # Procs report :parameters directly; other callables (methods, etc.) need it via #method(:call).
263
+ parameters = callable.respond_to?(:parameters) ? callable.parameters : callable.method(:call).parameters
84
264
 
85
- end
265
+ if parameters.any? { |type, _| type == :keyrest }
266
+ callable.call(**payload)
267
+ elsif parameters.any? { |type, _| type == :key || type == :keyreq }
268
+ names = parameters.select { |type, _| type == :key || type == :keyreq }.map { |_, name| name }
269
+ callable.call(**payload.slice(*names))
270
+ elsif parameters.any? { |type, _| type == :req || type == :opt || type == :rest }
271
+ callable.call(positional)
272
+ else
273
+ callable.call
274
+ end
275
+ end
86
276
 
87
- ActionController::Base.send :include, Spamtrap::Controller
277
+ private :spamtrap_nonce_failure, :spamtrap_too_fast?, :spamtrap_remap_params, :spamtrap_remap_hash,
278
+ :spamtrap_strict_violation, :spamtrap_trap, :spamtrap_render_trap, :spamtrap_invoke_on_trap,
279
+ :spamtrap_call, :spamtrap_js_proof_failure, :spamtrap_content_suspicious?
280
+
281
+ end
@@ -1,4 +1,6 @@
1
1
  require 'base64'
2
+ require 'securerandom'
3
+ require 'ipaddr'
2
4
 
3
5
  module Spamtrap
4
6
  module Crypto
@@ -7,40 +9,87 @@ module Spamtrap
7
9
  NONCE_LEN = 12
8
10
  TAG_LEN = 16
9
11
 
12
+ class << self
13
+ # HKDF-derives {mutation:, nonce:} keys, memoised per secret at module level so each
14
+ # request doesn't re-derive them; previous_secret_key_base is what keeps a rotation from
15
+ # orphaning in-flight forms.
16
+ def keys_for(secret)
17
+ @keys_cache ||= {}
18
+ @keys_cache[secret] ||= begin
19
+ derived = {
20
+ mutation: OpenSSL::KDF.hkdf(secret, salt: 'spamtrap-mutation', info: '', length: KEY_LEN, hash: 'SHA256'),
21
+ nonce: OpenSSL::KDF.hkdf(secret, salt: 'spamtrap-nonce', info: '', length: 32, hash: 'SHA256')
22
+ }
23
+ # Cap at the two most recent secrets so a test suite rotating secrets can't grow this unboundedly.
24
+ @keys_cache.delete(@keys_cache.keys.first) while @keys_cache.size > 1
25
+ derived
26
+ end
27
+ end
28
+ end
29
+
10
30
  private
11
31
 
12
- def spamtrap_crypto_key
13
- @spamtrap_crypto_key ||= OpenSSL::KDF.hkdf(
14
- Rails.application.secret_key_base,
15
- salt: 'spamtrap-mutation',
16
- info: '',
17
- length: KEY_LEN,
18
- hash: 'SHA256'
19
- )
32
+ # Appended only when context is non-nil, so the default AAD/message shape is unchanged.
33
+ def spamtrap_aad(timestamp, context)
34
+ context.nil? ? timestamp.to_s : "#{timestamp}:#{context}"
20
35
  end
21
36
 
22
- def spamtrap_encrypt_field(field_name, salt_bytes)
37
+ # aad binds the token to the render timestamp so it can't be replayed under a different one.
38
+ def spamtrap_encrypt_field(field_name, aad)
23
39
  cipher = OpenSSL::Cipher.new(CIPHER)
24
40
  cipher.encrypt
25
- cipher.key = spamtrap_crypto_key
26
- cipher.iv = salt_bytes
41
+ cipher.key = Spamtrap::Crypto.keys_for(Spamtrap.secret_key_base)[:mutation]
42
+ cipher.iv = iv = SecureRandom.bytes(NONCE_LEN)
43
+ cipher.auth_data = aad.to_s
27
44
  ct = cipher.update(field_name.to_s) + cipher.final
28
- Base64.urlsafe_encode64(ct + cipher.auth_tag(TAG_LEN), padding: false)
45
+ Base64.urlsafe_encode64(iv + ct + cipher.auth_tag(TAG_LEN), padding: false)
29
46
  end
30
47
 
31
- def spamtrap_decrypt_field(token, salt_bytes)
48
+ # Tries the current secret, then the previous one (rotation window), before giving up.
49
+ def spamtrap_decrypt_field(token, aad)
32
50
  raw = Base64.urlsafe_decode64(token)
33
- return nil if raw.bytesize <= TAG_LEN
34
- ct = raw[0, raw.bytesize - TAG_LEN]
51
+ return nil unless raw.bytesize > NONCE_LEN + TAG_LEN
52
+
53
+ [Spamtrap.secret_key_base, Spamtrap.previous_secret_key_base].compact.each do |secret|
54
+ result = spamtrap_decrypt_raw(raw, aad, secret)
55
+ return result if result
56
+ end
57
+ nil
58
+ rescue ArgumentError
59
+ nil
60
+ end
61
+
62
+ def spamtrap_decrypt_raw(raw, aad, secret)
63
+ iv = raw[0, NONCE_LEN]
64
+ ct = raw[NONCE_LEN...-TAG_LEN]
35
65
  tag = raw[-TAG_LEN..]
36
66
  cipher = OpenSSL::Cipher.new(CIPHER)
37
67
  cipher.decrypt
38
- cipher.key = spamtrap_crypto_key
39
- cipher.iv = salt_bytes
40
- cipher.auth_tag = tag
68
+ cipher.key = Spamtrap::Crypto.keys_for(secret)[:mutation]
69
+ cipher.iv = iv
70
+ cipher.auth_data = aad.to_s
71
+ cipher.auth_tag = tag
41
72
  (cipher.update(ct) + cipher.final).to_sym
42
- rescue OpenSSL::Cipher::CipherError, ArgumentError
73
+ rescue OpenSSL::Cipher::CipherError
43
74
  nil
44
75
  end
76
+
77
+ # Value the inline script writes into spamtrap_js. Visible in the page source by design:
78
+ # it proves a script ran, not that the client is honest.
79
+ def spamtrap_js_digest(timestamp, honeypot, secret: Spamtrap.secret_key_base, context: nil)
80
+ key = Spamtrap::Crypto.keys_for(secret)[:nonce]
81
+ message = "js:v1:#{timestamp}:#{honeypot}"
82
+ message += ":#{context}" unless context.nil? # appended only when present so the default digest is unchanged
83
+ OpenSSL::HMAC.hexdigest('SHA256', key, message)
84
+ end
85
+
86
+ # v1: versions the message for future key rotation; honeypot scopes a token to one form.
87
+ # secret defaults to current but the caller retries with previous_secret_key_base on mismatch.
88
+ def spamtrap_nonce_digest(timestamp, ip, honeypot, nonce_id, bind_ip: Spamtrap.nonce_bind_ip, secret: Spamtrap.secret_key_base, context: nil)
89
+ key = Spamtrap::Crypto.keys_for(secret)[:nonce]
90
+ message = "v1:#{timestamp}:#{Spamtrap.normalize_ip(ip, bind_ip)}:#{honeypot}:#{nonce_id}"
91
+ message += ":#{context}" unless context.nil? # appended only when present so the default digest is unchanged
92
+ OpenSSL::HMAC.hexdigest('SHA256', key, message)
93
+ end
45
94
  end
46
95
  end