reputable 0.1.25 → 0.1.26

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: 71bd7ca4b8c5f9ed7587e464ae373eb944a54e51c5fb2ba119c32e8e6337336a
4
- data.tar.gz: 2061b0c777149a2a689eed5dfa6c1ee429f4aa433d02fce31be31b200b46c93c
3
+ metadata.gz: 85f3feb44268a1779722dac5bd55976e534db3ea5d4ff86b541b96fb953c4c5d
4
+ data.tar.gz: a86937950a3032633c0bc81b4fce24ea833dc68c38f5eb7aecf071cecdbbc17f
5
5
  SHA512:
6
- metadata.gz: 9faf4fe50a95dc50a5e13d33c215ea3894de64dace21176f5cbfd0f8794f6365ca53edb34b4fbabf0a7a8136bdea109b1d9519856c9197a45d2bfdcd43077310
7
- data.tar.gz: bda70059416410192c65851f2ab96169958b87912c7e462fc350f866d59379d0e153419297c5c2b90aa26e9e65acab5c5f32ed293ce3bc30b878609b29823c90
6
+ metadata.gz: 851d6c143940682015369cb1592fad14742e271550dcc36bb2717f404c0c3c13883ed7abc7de6ffaee8b3baa34e15850353aa55085e5a5f79955a6f16ceb687a
7
+ data.tar.gz: b9cc8b8dacbbbc9731a806d15380e3455ae21ab78e0b0a09990a6db721cc26bbae51383afffb9a0de07fc7953a4098a4d06340d7bbc23ea2a1e84c7e8197c56f
data/Gemfile.lock CHANGED
@@ -1,7 +1,8 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- reputable (0.1.25)
4
+ reputable (0.1.26)
5
+ base64 (>= 0.2, < 0.4)
5
6
  connection_pool (~> 2.2)
6
7
  redis (>= 4.0, < 6.0)
7
8
 
@@ -9,25 +10,26 @@ GEM
9
10
  remote: https://rubygems.org/
10
11
  specs:
11
12
  ast (2.4.3)
13
+ base64 (0.3.0)
12
14
  connection_pool (2.5.5)
13
15
  diff-lcs (1.6.2)
14
- json (2.18.0)
16
+ json (2.19.8)
15
17
  language_server-protocol (3.17.0.5)
16
18
  lint_roller (1.1.0)
17
- parallel (1.27.0)
18
- parser (3.3.10.0)
19
+ parallel (1.28.0)
20
+ parser (3.3.11.1)
19
21
  ast (~> 2.4.1)
20
22
  racc
21
- prism (1.7.0)
23
+ prism (1.9.0)
22
24
  racc (1.8.1)
23
- rack (2.2.21)
25
+ rack (2.2.23)
24
26
  rainbow (3.1.1)
25
- rake (13.3.1)
27
+ rake (13.4.2)
26
28
  redis (5.4.1)
27
29
  redis-client (>= 0.22.0)
28
- redis-client (0.26.2)
30
+ redis-client (0.29.0)
29
31
  connection_pool
30
- regexp_parser (2.11.3)
32
+ regexp_parser (2.12.0)
31
33
  rspec (3.13.2)
32
34
  rspec-core (~> 3.13.0)
33
35
  rspec-expectations (~> 3.13.0)
@@ -37,24 +39,24 @@ GEM
37
39
  rspec-expectations (3.13.5)
38
40
  diff-lcs (>= 1.2.0, < 2.0)
39
41
  rspec-support (~> 3.13.0)
40
- rspec-mocks (3.13.7)
42
+ rspec-mocks (3.13.8)
41
43
  diff-lcs (>= 1.2.0, < 2.0)
42
44
  rspec-support (~> 3.13.0)
43
- rspec-support (3.13.6)
44
- rubocop (1.82.0)
45
+ rspec-support (3.13.7)
46
+ rubocop (1.87.0)
45
47
  json (~> 2.3)
46
48
  language_server-protocol (~> 3.17.0.2)
47
49
  lint_roller (~> 1.1.0)
48
- parallel (~> 1.10)
50
+ parallel (>= 1.10)
49
51
  parser (>= 3.3.0.2)
50
52
  rainbow (>= 2.2.2, < 4.0)
51
53
  regexp_parser (>= 2.9.3, < 3.0)
52
- rubocop-ast (>= 1.48.0, < 2.0)
54
+ rubocop-ast (>= 1.49.0, < 2.0)
53
55
  ruby-progressbar (~> 1.7)
54
56
  unicode-display_width (>= 2.4.0, < 4.0)
55
- rubocop-ast (1.48.0)
57
+ rubocop-ast (1.49.1)
56
58
  parser (>= 3.3.7.2)
57
- prism (~> 1.4)
59
+ prism (~> 1.7)
58
60
  ruby-progressbar (1.13.0)
59
61
  unicode-display_width (3.2.0)
60
62
  unicode-emoji (~> 4.1)
@@ -69,8 +71,8 @@ DEPENDENCIES
69
71
  rack (~> 2.0)
70
72
  rake (~> 13.0)
71
73
  reputable!
72
- rspec (~> 3.0)
73
- rubocop (~> 1.21)
74
+ rspec (~> 3.12, ~> 3.0)
75
+ rubocop (~> 1.21, ~> 1.0)
74
76
 
75
77
  BUNDLED WITH
76
- 2.5.9
78
+ 4.0.14
data/README.md CHANGED
@@ -245,6 +245,77 @@ REPUTABLE_SSL_VERIFY=false
245
245
 
246
246
  ---
247
247
 
248
+ ## Hosted Measurement
249
+
250
+ Hosted measurement is the shared `cloud.reputable.click` path for per-request
251
+ proxy, bot, timing, ASN, geo, and anomaly checks. It does not require Redis and
252
+ does not replace the dedicated Reputable middleware path above.
253
+
254
+ Configure the customer credentials issued from the Reputable `/measure` page:
255
+
256
+ ```ruby
257
+ Reputable.configure do |config|
258
+ config.measurement_base_url = ENV["REPUTABLE_MEASURE_BASE_URL"] # e.g. https://cloud.reputable.click
259
+ config.measurement_customer_id = ENV["REPUTABLE_MEASURE_CUSTOMER_ID"]
260
+ config.measurement_capability = ENV["REPUTABLE_MEASURE_CAPABILITY"]
261
+ config.measurement_private_jwk = JSON.parse(ENV.fetch("REPUTABLE_MEASURE_PRIVATE_JWK"))
262
+ end
263
+ ```
264
+
265
+ Mint a short-lived token for the browser to send to the hosted collect endpoint:
266
+
267
+ ```ruby
268
+ token = Reputable.measurement_token(
269
+ mode: "collect",
270
+ session_ref: session.id,
271
+ request_id: request.request_id,
272
+ origin: request.base_url,
273
+ request_context: {
274
+ scheme: request.scheme,
275
+ host: request.host,
276
+ method: request.request_method,
277
+ path: request.path,
278
+ query: request.query_string,
279
+ page_type: "product",
280
+ action_type: "view"
281
+ }
282
+ )
283
+
284
+ collect_url = Reputable.measurement_collect_url(token)
285
+ ```
286
+
287
+ After the browser returns the signed `resultToken`, verify it server-side before
288
+ allowing an expensive or abuse-sensitive action:
289
+
290
+ ```ruby
291
+ result = Reputable.verify_measurement_result(result_token)
292
+
293
+ if result && result["outcome"] != "deny"
294
+ # Continue with the protected action.
295
+ else
296
+ # Review, challenge, or block according to your app policy.
297
+ end
298
+ ```
299
+
300
+ For offline verification without fetching JWKS from the measurement fleet, set:
301
+
302
+ ```ruby
303
+ config.measurement_result_jwks = JSON.parse(ENV.fetch("REPUTABLE_MEASURE_RESULT_JWKS"))
304
+ ```
305
+
306
+ Relevant environment variables:
307
+
308
+ ```bash
309
+ REPUTABLE_MEASURE_BASE_URL=https://cloud.reputable.click
310
+ REPUTABLE_MEASURE_CUSTOMER_ID=cus_...
311
+ REPUTABLE_MEASURE_CAPABILITY=eyJ...
312
+ REPUTABLE_MEASURE_PRIVATE_JWK='{"kty":"EC","crv":"P-256",...}'
313
+ REPUTABLE_MEASURE_RESULT_JWKS='{"keys":[...]}'
314
+ REPUTABLE_MEASURE_TOKEN_TTL=120
315
+ ```
316
+
317
+ ---
318
+
248
319
  ## Middleware Configuration
249
320
 
250
321
  ### Basic Usage
@@ -471,6 +542,43 @@ Reputable.track_request_async(
471
542
  )
472
543
  ```
473
544
 
545
+ ### Trusted Security Events
546
+
547
+ For the full cross-service contract and Go API behavior, see [docs/SECURITY_EVENTS.md](../../docs/SECURITY_EVENTS.md).
548
+
549
+ ```ruby
550
+ Reputable.track_security_event(
551
+ event_type: "login_failed",
552
+ ip: request.ip,
553
+ account_id: current_account.id,
554
+ user_id: current_user.id,
555
+ session_id: session.id.to_s,
556
+ request_id: request.request_id,
557
+ reason_code: "invalid_password",
558
+ context: {
559
+ country: "US",
560
+ proxy_class: "none",
561
+ ja4: request.headers["X-JA4"],
562
+ ua_family: "chrome"
563
+ },
564
+ resource: {
565
+ type: "account",
566
+ id: current_account.id,
567
+ action: "login"
568
+ },
569
+ metadata: { auth_method: "password" }
570
+ )
571
+
572
+ # Async (recommended)
573
+ Reputable.track_security_event_async(
574
+ event_type: "permission_denied",
575
+ ip: request.ip,
576
+ account_id: current_account.id,
577
+ user_id: current_user.id,
578
+ reason_code: "missing_scope"
579
+ )
580
+ ```
581
+
474
582
  ### Reputation Management
475
583
 
476
584
  ```ruby
@@ -645,14 +753,14 @@ If you need to verify manually (e.g., custom controller logic):
645
753
 
646
754
  ```ruby
647
755
  # In your controller action (the return_url target)
648
- if params[:reputable_signature]
756
+ if params[:reputable_s] || params[:reputable_signature]
649
757
  if Reputable.verify_redirect_return(params)
650
758
  # Signature is VALID. Meaning Reputable actually sent this user back.
651
-
652
- # Inspect outcomes
653
- status = params[:reputable_status] # 'pass', 'fail'
654
- outcome = params[:reputable_outcome] # 'trusted_verified', 'trusted_behavior', 'unknown'
655
- country = params[:reputable_country] # 'US', 'DE', etc.
759
+
760
+ decoded = Reputable.decode_reputable_response(params)
761
+ status = decoded&.dig("status") || params[:reputable_status]
762
+ outcome = decoded&.dig("outcome") || params[:reputable_outcome]
763
+ country = decoded&.dig("country") || params[:reputable_country]
656
764
 
657
765
  if status == 'pass'
658
766
  # Grant access
@@ -665,14 +773,21 @@ end
665
773
  ```
666
774
 
667
775
  **Return Parameters:**
668
- The return URL will contain:
669
- - `reputable_status`: `pass` or `fail`
670
- - `reputable_session_id`: The session ID you provided
671
- - `reputable_outcome`: The specific reputation outcome (e.g., `trusted_verified`)
672
- - `reputable_ignore_analytics`: 'true'/'false' flag
673
- - `reputable_country`: ISO country code
674
- - `reputable_challenge_passed`: 'true' when an interactive challenge was completed
675
- - `reputable_signature`: HMAC-SHA256 signature of the above
776
+ The current Go API returns compact signed parameters:
777
+
778
+ - `reputable_r`: Base64url-encoded response payload
779
+ - `reputable_s`: HMAC-SHA256 signature of the decoded payload
780
+
781
+ Decode `reputable_r` with `Reputable.decode_reputable_response(params)` to read:
782
+
783
+ - `status`: `pass` or `fail`
784
+ - `session_id`: The session ID you provided
785
+ - `outcome`: The specific reputation outcome (e.g., `trusted_verified`)
786
+ - `ignore_analytics`: `true`/`false` flag
787
+ - `country`: ISO country code
788
+ - `challenge_passed`: `true` when an interactive challenge was completed
789
+
790
+ The Ruby client still accepts the legacy expanded parameters (`reputable_status`, `reputable_session_id`, `reputable_outcome`, `reputable_ignore_analytics`, `reputable_country`, `reputable_challenge_passed`, `reputable_signature`) for backward compatibility.
676
791
 
677
792
  ---
678
793
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "openssl"
4
4
  require "ipaddr"
5
+ require "json"
5
6
 
6
7
  module Reputable
7
8
  # Configuration class for Reputable client
@@ -15,6 +16,11 @@ module Reputable
15
16
  :connect_timeout, :read_timeout, :write_timeout,
16
17
  :ssl_params, :trusted_proxies, :ip_header_priority,
17
18
  :on_error, :trusted_keys, :base_url,
19
+ :measurement_base_url, :measurement_customer_id,
20
+ :measurement_result_jwks, :measurement_audience,
21
+ :signals_secret_key, :signals_publishable_key,
22
+ :measurement_capability, :measurement_private_jwk,
23
+ :measurement_token_ttl,
18
24
  :site_name, :support_email, :support_url,
19
25
  :verbose
20
26
 
@@ -80,6 +86,17 @@ module Reputable
80
86
  @trusted_keys = [ENV["REPUTABLE_SECRET_KEY"]]
81
87
  end
82
88
  @base_url = ENV.fetch("REPUTABLE_BASE_URL", "https://api.reputable.click")
89
+ @measurement_base_url = ENV["REPUTABLE_SIGNALS_BASE_URL"] || ENV["REPUTABLE_MEASURE_BASE_URL"]
90
+ @measurement_customer_id = ENV["REPUTABLE_SIGNALS_CUSTOMER_ID"] || ENV["REPUTABLE_MEASURE_CUSTOMER_ID"]
91
+ @measurement_result_jwks = parse_json_env("REPUTABLE_SIGNALS_JWKS") || parse_json_env("REPUTABLE_MEASURE_RESULT_JWKS")
92
+ @measurement_audience = ENV.fetch("REPUTABLE_MEASURE_AUDIENCE", "reputable-measure")
93
+ @signals_secret_key = ENV["REPUTABLE_SIGNALS_SECRET_KEY"]
94
+ @signals_publishable_key = ENV["REPUTABLE_SIGNALS_PUBLISHABLE_KEY"]
95
+ # Legacy capability-era settings; kept as readable attributes so old
96
+ # initializers do not crash, but no longer used.
97
+ @measurement_capability = ENV["REPUTABLE_MEASURE_CAPABILITY"]
98
+ @measurement_private_jwk = parse_json_env("REPUTABLE_MEASURE_PRIVATE_JWK")
99
+ @measurement_token_ttl = Integer(ENV.fetch("REPUTABLE_MEASURE_TOKEN_TTL", "120"))
83
100
  @site_name = ENV["REPUTABLE_SITE_NAME"]
84
101
  @support_email = ENV["REPUTABLE_SUPPORT_EMAIL"]
85
102
  @support_url = ENV["REPUTABLE_SUPPORT_URL"]
@@ -124,6 +141,15 @@ module Reputable
124
141
  @default_ttls[status_sym] || DEFAULT_TTLS[:untrusted_challenge]
125
142
  end
126
143
 
144
+ def parse_json_env(name)
145
+ raw = ENV[name]
146
+ return nil if raw.nil? || raw.empty?
147
+
148
+ JSON.parse(raw)
149
+ rescue JSON::ParserError
150
+ nil
151
+ end
152
+
127
153
  # Check if Redis URL uses TLS (rediss:// scheme)
128
154
  def tls?
129
155
  redis_url&.start_with?("rediss://")
@@ -0,0 +1,238 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+ require "net/http"
6
+ require "openssl"
7
+ require "securerandom"
8
+ require "time"
9
+ require "uri"
10
+
11
+ module Reputable
12
+ # Helpers for the hosted Bot Signals API.
13
+ #
14
+ # This path is independent from the Redis-backed reputation middleware. It
15
+ # verifies signed tickets offline against the published JWKS. The old
16
+ # customer-minted measurement-token flow is gone: servers call the check API
17
+ # with a plain secret key (see Reputable::Signals) and browsers use a
18
+ # publishable key.
19
+ module Measurement
20
+ DEFAULT_AUDIENCE = "reputable-measure"
21
+
22
+ MINTING_REMOVED = "Reputable no longer uses customer-minted measurement tokens. " \
23
+ "Use your secret key with Reputable::Signals.check (server) or your " \
24
+ "publishable key in the browser snippet/redirect URL instead."
25
+
26
+ module_function
27
+
28
+ # @deprecated The two-key model replaced customer-minted JWTs.
29
+ def measurement_token(**)
30
+ raise NotImplementedError, MINTING_REMOVED
31
+ end
32
+
33
+ # @deprecated See {Reputable::Signals.redirect_url}.
34
+ def collect_url(_token = nil)
35
+ raise NotImplementedError, MINTING_REMOVED
36
+ end
37
+
38
+ # @deprecated See {Reputable::Signals.redirect_url}.
39
+ def redirect_url(_token = nil)
40
+ raise NotImplementedError, MINTING_REMOVED
41
+ end
42
+
43
+ def verify_result_token(token, jwks: nil, expected_customer_id: nil, audience: nil)
44
+ return nil if blank?(token)
45
+
46
+ jwks ||= Reputable.configuration.measurement_result_jwks || fetch_measurement_jwks
47
+ keys = Array(jwks.is_a?(Hash) ? jwks["keys"] || jwks[:keys] : jwks)
48
+ return nil if keys.empty?
49
+
50
+ header, payload, signed_data, signature = decode_jwt(token)
51
+ return nil unless header["alg"] == "ES256"
52
+
53
+ key = select_key(keys, header["kid"])
54
+ return nil unless key && verify_es256_signature(key, signed_data, signature)
55
+
56
+ now = Time.now.to_i
57
+ return nil if payload["exp"] && now >= payload["exp"].to_i
58
+ return nil if payload["nbf"] && now < payload["nbf"].to_i
59
+
60
+ expected_customer_id ||= Reputable.configuration.measurement_customer_id
61
+ audience ||= expected_customer_id
62
+ return nil if present?(expected_customer_id) && payload["customerId"] != expected_customer_id
63
+ return nil if present?(audience) && !audience_matches?(payload["aud"], audience)
64
+
65
+ payload
66
+ rescue StandardError => e
67
+ Reputable.logger&.warn "Reputable: Failed to verify measurement result: #{e.message}"
68
+ nil
69
+ end
70
+
71
+ def verify_result(token, **kwargs)
72
+ claims = verify_result_token(token, **kwargs)
73
+ claims && (claims["report"] || claims["result"])
74
+ end
75
+
76
+ def measurement_base_url
77
+ (Reputable.configuration.measurement_base_url || Reputable.configuration.base_url).to_s.chomp("/")
78
+ end
79
+
80
+ def fetch_measurement_jwks
81
+ fetch_jwks_path("/v1/signals/jwks") || fetch_jwks_path("/v1/verify/jwks")
82
+ end
83
+
84
+ def fetch_jwks_path(path)
85
+ uri = URI("#{measurement_base_url}#{path}")
86
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
87
+ open_timeout: Reputable.configuration.connect_timeout,
88
+ read_timeout: Reputable.configuration.read_timeout) do |http|
89
+ http.get(uri.request_uri)
90
+ end
91
+ return nil unless response.is_a?(Net::HTTPSuccess)
92
+
93
+ JSON.parse(response.body)
94
+ rescue StandardError => e
95
+ Reputable.logger&.warn "Reputable: Failed to fetch JWKS from #{path}: #{e.message}"
96
+ nil
97
+ end
98
+
99
+ def normalize_request_context(value)
100
+ raw = stringify_keys(value || {})
101
+ {
102
+ scheme: raw["scheme"],
103
+ host: raw["host"],
104
+ method: raw["method"]&.upcase,
105
+ path: raw["path"],
106
+ query: raw["query"],
107
+ url: raw["url"],
108
+ referrer: raw["referrer"] || raw["referer"],
109
+ pageTitle: raw["pageTitle"] || raw["page_title"],
110
+ pageType: raw["pageType"] || raw["page_type"],
111
+ actionType: raw["actionType"] || raw["action_type"]
112
+ }.compact
113
+ end
114
+
115
+ def sign_es256_jwt(payload, private_jwk)
116
+ jwk = stringify_keys(private_jwk)
117
+ header = { alg: "ES256", typ: "JWT" }
118
+ header[:kid] = jwk["kid"] if present?(jwk["kid"])
119
+
120
+ signed_data = "#{base64url_encode(JSON.generate(header))}.#{base64url_encode(JSON.generate(payload))}"
121
+ der_signature = ec_private_key_from_jwk(jwk).dsa_sign_asn1(OpenSSL::Digest::SHA256.digest(signed_data))
122
+ "#{signed_data}.#{base64url_encode(der_to_jose_signature(der_signature))}"
123
+ end
124
+
125
+ def decode_jwt(token)
126
+ parts = token.to_s.split(".")
127
+ raise ArgumentError, "invalid JWT" unless parts.length == 3
128
+
129
+ header = JSON.parse(base64url_decode(parts[0]))
130
+ payload = JSON.parse(base64url_decode(parts[1]))
131
+ [header, payload, "#{parts[0]}.#{parts[1]}", base64url_decode(parts[2])]
132
+ end
133
+
134
+ def verify_es256_signature(jwk, signed_data, jose_signature)
135
+ return false unless jose_signature.bytesize == 64
136
+
137
+ digest = OpenSSL::Digest::SHA256.digest(signed_data)
138
+ ec_public_key_from_jwk(stringify_keys(jwk)).dsa_verify_asn1(digest, jose_to_der_signature(jose_signature))
139
+ rescue StandardError
140
+ false
141
+ end
142
+
143
+ # Keys are built from DER rather than assigned via key.private_key= /
144
+ # key.public_key=, which raise "pkeys are immutable" on OpenSSL 3.
145
+ def ec_private_key_from_jwk(jwk)
146
+ d = base64url_decode(jwk.fetch("d"))
147
+ asn1 = OpenSSL::ASN1::Sequence([
148
+ OpenSSL::ASN1::Integer(1),
149
+ OpenSSL::ASN1::OctetString(d.rjust(32, "\x00")),
150
+ OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::ObjectId("prime256v1")], 0, :CONTEXT_SPECIFIC),
151
+ OpenSSL::ASN1::ASN1Data.new(
152
+ [OpenSSL::ASN1::BitString(public_point_from_jwk(jwk).to_octet_string(:uncompressed))],
153
+ 1, :CONTEXT_SPECIFIC
154
+ )
155
+ ])
156
+ OpenSSL::PKey::EC.new(asn1.to_der)
157
+ end
158
+
159
+ def ec_public_key_from_jwk(jwk)
160
+ asn1 = OpenSSL::ASN1::Sequence([
161
+ OpenSSL::ASN1::Sequence([
162
+ OpenSSL::ASN1::ObjectId("id-ecPublicKey"),
163
+ OpenSSL::ASN1::ObjectId("prime256v1")
164
+ ]),
165
+ OpenSSL::ASN1::BitString(public_point_from_jwk(jwk).to_octet_string(:uncompressed))
166
+ ])
167
+ OpenSSL::PKey::EC.new(asn1.to_der)
168
+ end
169
+
170
+ def public_point_from_jwk(jwk)
171
+ group = OpenSSL::PKey::EC::Group.new("prime256v1")
172
+ x = base64url_decode(jwk.fetch("x"))
173
+ y = base64url_decode(jwk.fetch("y"))
174
+ OpenSSL::PKey::EC::Point.new(group, OpenSSL::BN.new("\x04#{x}#{y}", 2))
175
+ end
176
+
177
+ def der_to_jose_signature(der)
178
+ sequence = OpenSSL::ASN1.decode(der)
179
+ r = integer_to_fixed_bytes(sequence.value[0].value.to_i, 32)
180
+ s = integer_to_fixed_bytes(sequence.value[1].value.to_i, 32)
181
+ "#{r}#{s}"
182
+ end
183
+
184
+ def jose_to_der_signature(signature)
185
+ r = OpenSSL::BN.new(signature[0, 32], 2)
186
+ s = OpenSSL::BN.new(signature[32, 32], 2)
187
+ OpenSSL::ASN1::Sequence([
188
+ OpenSSL::ASN1::Integer(r),
189
+ OpenSSL::ASN1::Integer(s)
190
+ ]).to_der
191
+ end
192
+
193
+ def integer_to_fixed_bytes(value, width)
194
+ hex = value.to_s(16)
195
+ hex = "0#{hex}" if hex.length.odd?
196
+ bytes = [hex].pack("H*")
197
+ bytes.rjust(width, "\x00")[-width, width]
198
+ end
199
+
200
+ def select_key(keys, kid)
201
+ keys = keys.map { |key| stringify_keys(key) }
202
+ return keys.first if blank?(kid) && keys.length == 1
203
+
204
+ keys.find { |key| key["kid"] == kid }
205
+ end
206
+
207
+ def audience_matches?(actual, expected)
208
+ Array(actual).map(&:to_s).include?(expected.to_s)
209
+ end
210
+
211
+ def stringify_keys(value)
212
+ return value unless value.is_a?(Hash)
213
+
214
+ value.each_with_object({}) { |(key, val), out| out[key.to_s] = val }
215
+ end
216
+
217
+ def base64url_encode(value)
218
+ Base64.urlsafe_encode64(value).delete("=")
219
+ end
220
+
221
+ def base64url_decode(value)
222
+ padded = value.to_s + "=" * ((4 - value.to_s.length % 4) % 4)
223
+ Base64.urlsafe_decode64(padded)
224
+ end
225
+
226
+ def escape(value)
227
+ URI.encode_www_form_component(value.to_s)
228
+ end
229
+
230
+ def present?(value)
231
+ !blank?(value)
232
+ end
233
+
234
+ def blank?(value)
235
+ value.nil? || (value.respond_to?(:empty?) && value.empty?)
236
+ end
237
+ end
238
+ end
@@ -456,6 +456,9 @@ module Reputable
456
456
  path: request.path,
457
457
  query: request.query_string.empty? ? nil : request.query_string,
458
458
  method: request.request_method,
459
+ account_id: tracking_account_id(env),
460
+ session_id: tracking_session_id(env),
461
+ user_id: tracking_user_id(env),
459
462
  user_agent: env["HTTP_USER_AGENT"],
460
463
  referer: env["HTTP_REFERER"],
461
464
  tags: build_tags(env),
@@ -466,6 +469,34 @@ module Reputable
466
469
  nil
467
470
  end
468
471
 
472
+ def tracking_session_id(env)
473
+ resolve_session_id(env)
474
+ rescue StandardError
475
+ nil
476
+ end
477
+
478
+ def tracking_user_id(env)
479
+ return env["reputable.user_id"] if env["reputable.user_id"]
480
+
481
+ session = env["rack.session"]
482
+ return nil unless session
483
+
484
+ session[:user_id] || session["user_id"]
485
+ rescue StandardError
486
+ nil
487
+ end
488
+
489
+ def tracking_account_id(env)
490
+ return env["reputable.account_id"] if env["reputable.account_id"]
491
+
492
+ session = env["rack.session"]
493
+ return nil unless session
494
+
495
+ session[:account_id] || session["account_id"] || session[:tenant_id] || session["tenant_id"]
496
+ rescue StandardError
497
+ nil
498
+ end
499
+
469
500
  def extract_ip(env)
470
501
  config = Reputable.configuration
471
502
 
@@ -25,6 +25,41 @@ module Reputable
25
25
  )
26
26
  end
27
27
 
28
+ # Track a trusted backend security event for user/account profiling.
29
+ def track_reputable_security_event(
30
+ event_type:,
31
+ outcome: nil,
32
+ reason_code: nil,
33
+ account_id: nil,
34
+ user_id: nil,
35
+ context: {},
36
+ resource: nil,
37
+ metadata: {},
38
+ **options
39
+ )
40
+ base_context = {
41
+ ip: request.remote_ip
42
+ }.merge(context || {})
43
+
44
+ Reputable::Tracker.track_security_event(
45
+ event_type: event_type,
46
+ ip: request.remote_ip,
47
+ path: request.path,
48
+ outcome: outcome,
49
+ reason_code: reason_code,
50
+ account_id: account_id,
51
+ user_id: user_id,
52
+ session_id: options[:session_id] || session.id,
53
+ request_id: options[:request_id] || request.request_id,
54
+ user_agent: request.user_agent,
55
+ referer: request.referer,
56
+ context: base_context,
57
+ resource: resource,
58
+ metadata: metadata,
59
+ **options
60
+ )
61
+ end
62
+
28
63
  # Trust the current user's IP (behavioral by default, verified optional)
29
64
  def trust_current_ip(reason:, status: :trusted_behavior, ttl: nil, **metadata)
30
65
  Reputable::Reputation.trust_ip(
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "json"
5
+ require "net/http"
6
+ require "uri"
7
+
8
+ module Reputable
9
+ # Bot Signals server API: ask the hosted fleet for a signal report on an end
10
+ # user's request using your secret key (sk_...). No JWT minting — a plain
11
+ # bearer.
12
+ #
13
+ # @example Check a request before an expensive action
14
+ # check = Reputable::Signals.check(ip: request.remote_ip, user_agent: request.user_agent)
15
+ # report = check && check["report"]
16
+ # flag_traffic if report && report["proxyScore"].to_i >= 70
17
+ module Signals
18
+ module_function
19
+
20
+ # POST /v1/signals/check with the configured secret key. Pass the END
21
+ # USER's ip/user_agent — without them the fleet would score your server's
22
+ # own connection. Returns the parsed response hash ({"ticket", "report"})
23
+ # or nil on failure.
24
+ def check(ip: nil, user_agent: nil, session_ref: nil, request_id: nil, request_context: nil,
25
+ detail: false, secret_key: nil)
26
+ secret_key ||= Reputable.configuration.signals_secret_key
27
+ return nil if secret_key.nil? || secret_key.empty?
28
+
29
+ uri = URI("#{Measurement.measurement_base_url}/v1/signals/check")
30
+ payload = {
31
+ ip: ip,
32
+ userAgent: user_agent,
33
+ sessionRef: session_ref,
34
+ requestId: request_id,
35
+ detail: detail || nil
36
+ }
37
+ payload[:request] = Measurement.normalize_request_context(request_context) if request_context
38
+
39
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
40
+ open_timeout: Reputable.configuration.connect_timeout,
41
+ read_timeout: Reputable.configuration.read_timeout) do |http|
42
+ request = Net::HTTP::Post.new(uri.request_uri, "Content-Type" => "application/json",
43
+ "Authorization" => "Bearer #{secret_key}")
44
+ request.body = JSON.generate(payload.compact)
45
+ http.request(request)
46
+ end
47
+ return nil unless response.is_a?(Net::HTTPSuccess)
48
+
49
+ data = JSON.parse(response.body)
50
+ data["report"] ||= data["result"]
51
+ data["ticket"] ||= data["proof"]
52
+ data
53
+ rescue StandardError => e
54
+ Reputable.logger&.warn "Reputable: signals check failed: #{e.message}"
55
+ nil
56
+ end
57
+
58
+ # Build a redirect-flow URL: send a visitor through the fleet and back to
59
+ # +return_url+ (which must be registered on the publishable key) with a
60
+ # signed ticket attached as ?reputable_ticket=...
61
+ def redirect_url(return_url:, key: nil, request_id: nil, session_ref: nil)
62
+ key ||= Reputable.configuration.signals_publishable_key
63
+ raise ArgumentError, "publishable key is required" if key.nil? || key.empty?
64
+ raise ArgumentError, "return_url is required" if return_url.nil? || return_url.empty?
65
+
66
+ params = { key: key, return: return_url, request_id: request_id, session: session_ref }.compact
67
+ query = params.map { |k, v| "#{k}=#{CGI.escape(v.to_s)}" }.join("&")
68
+ "#{Measurement.measurement_base_url}/v1/signals/redirect?#{query}"
69
+ end
70
+
71
+ # Build a hosted challenge-page URL. The return URL must be registered on
72
+ # the publishable key. Clean traffic redirects back immediately; suspect
73
+ # traffic receives the key's configured CAPTCHA.
74
+ def challenge_page_url(return_url:, key: nil, request_id: nil, session_ref: nil)
75
+ key ||= Reputable.configuration.signals_publishable_key
76
+ raise ArgumentError, "publishable key is required" if key.nil? || key.empty?
77
+ raise ArgumentError, "return_url is required" if return_url.nil? || return_url.empty?
78
+
79
+ params = { key: key, return: return_url, request_id: request_id, session: session_ref }.compact
80
+ query = params.map { |k, v| "#{k}=#{CGI.escape(v.to_s)}" }.join("&")
81
+ "#{Measurement.measurement_base_url}/v1/signals/challenge-page?#{query}"
82
+ end
83
+ end
84
+ end
@@ -68,6 +68,81 @@ module Reputable
68
68
  true
69
69
  end
70
70
 
71
+ # Track a trusted backend security event (risk_event_v1 envelope).
72
+ #
73
+ # This is transported through the request buffer with metadata.risk_event_v1
74
+ # so existing classification/rule pipelines can consume resulting tags.
75
+ def track_security_event(
76
+ event_type:,
77
+ ip: nil,
78
+ account_id: nil,
79
+ user_id: nil,
80
+ session_id: nil,
81
+ request_id: nil,
82
+ outcome: nil,
83
+ reason_code: nil,
84
+ context: {},
85
+ resource: nil,
86
+ metadata: {},
87
+ occurred_at_ms: nil,
88
+ path: "/_trusted/security_event",
89
+ **options
90
+ )
91
+ return false unless Reputable.enabled?
92
+ normalized_event_type = normalize_token(event_type)
93
+ return false if normalized_event_type.nil? || normalized_event_type.empty?
94
+
95
+ event_context = normalize_risk_context(context)
96
+ effective_ip = ip || event_context[:ip]
97
+ return false if effective_ip.nil? || effective_ip.to_s.strip.empty?
98
+
99
+ risk_event = build_risk_event_v1(
100
+ event_type: normalized_event_type,
101
+ occurred_at_ms: occurred_at_ms,
102
+ account_id: account_id || options[:account_id],
103
+ user_id: user_id || options[:user_id],
104
+ session_id: session_id || options[:session_id],
105
+ request_id: request_id || options[:request_id],
106
+ outcome: outcome,
107
+ reason_code: reason_code,
108
+ context: event_context,
109
+ resource: resource,
110
+ metadata: metadata
111
+ )
112
+
113
+ entry_metadata = merge_metadata(options[:metadata], metadata)
114
+ entry_metadata[:risk_event_v1] = risk_event
115
+
116
+ tags = Array(options[:tags]) + security_tags_for_event(risk_event)
117
+
118
+ entry = build_request_entry(effective_ip, path, options.merge(
119
+ method: options[:method] || "POST",
120
+ account_id: account_id || options[:account_id],
121
+ user_id: user_id || options[:user_id],
122
+ session_id: session_id || options[:session_id],
123
+ request_id: request_id || options[:request_id],
124
+ tags: tags,
125
+ metadata: entry_metadata
126
+ ))
127
+
128
+ push_to_buffer(:request, entry)
129
+ rescue StandardError => e
130
+ Reputable.logger&.debug("Reputable track_security_event error: #{e.class} - #{e.message}")
131
+ false
132
+ end
133
+
134
+ # Fire-and-forget variant of track_security_event.
135
+ def track_security_event_async(**kwargs)
136
+ return true unless Reputable.enabled?
137
+
138
+ Thread.new do
139
+ track_security_event(**kwargs)
140
+ rescue StandardError
141
+ # Silently ignore all errors in async thread
142
+ end
143
+ true
144
+ end
145
+
71
146
  private
72
147
 
73
148
  def build_request_entry(ip, path, options)
@@ -77,16 +152,114 @@ module Reputable
77
152
  path: path,
78
153
  query: options[:query],
79
154
  method: options[:method] || "GET",
155
+ account_id: options[:account_id],
156
+ session_id: options[:session_id],
157
+ user_id: options[:user_id],
80
158
  fingerprint: options[:fingerprint],
81
159
  user_agent: options[:user_agent],
82
160
  referer: options[:referer],
83
161
  country: options[:country],
84
- tags: options[:tags] || [],
162
+ tags: Array(options[:tags]),
85
163
  metadata: options[:metadata],
86
164
  request_id: options[:request_id]
87
165
  }.compact
88
166
  end
89
167
 
168
+ def build_risk_event_v1(
169
+ event_type:,
170
+ occurred_at_ms: nil,
171
+ account_id: nil,
172
+ user_id: nil,
173
+ session_id: nil,
174
+ request_id: nil,
175
+ outcome: nil,
176
+ reason_code: nil,
177
+ context: {},
178
+ resource: nil,
179
+ metadata: {}
180
+ )
181
+ {
182
+ event_type: normalize_token(event_type),
183
+ occurred_at_ms: occurred_at_ms || (Time.now.to_f * 1000).to_i,
184
+ account_id: account_id,
185
+ user_id: user_id,
186
+ session_id: session_id,
187
+ request_id: request_id,
188
+ outcome: normalize_token(outcome),
189
+ reason_code: normalize_token(reason_code),
190
+ context: context,
191
+ resource: normalize_resource(resource),
192
+ metadata: metadata
193
+ }.compact
194
+ end
195
+
196
+ def normalize_risk_context(context)
197
+ hash = context.respond_to?(:to_h) ? context.to_h : {}
198
+ {
199
+ ip: hash[:ip] || hash["ip"],
200
+ country: normalize_token(hash[:country] || hash["country"]),
201
+ asn: (hash[:asn] || hash["asn"])&.to_s,
202
+ proxy_class: normalize_token(hash[:proxy_class] || hash["proxy_class"]),
203
+ ja4: (hash[:ja4] || hash["ja4"])&.to_s,
204
+ ua_family: normalize_token(hash[:ua_family] || hash["ua_family"])
205
+ }.compact
206
+ end
207
+
208
+ def normalize_resource(resource)
209
+ return nil if resource.nil?
210
+
211
+ hash = resource.respond_to?(:to_h) ? resource.to_h : {}
212
+ {
213
+ type: normalize_token(hash[:type] || hash["type"]),
214
+ id: (hash[:id] || hash["id"])&.to_s,
215
+ action: normalize_token(hash[:action] || hash["action"])
216
+ }.compact
217
+ end
218
+
219
+ def security_tags_for_event(risk_event)
220
+ event_type = risk_event[:event_type]
221
+ outcome = risk_event[:outcome]
222
+ tags = ["security:event", "security:event:#{event_type}"]
223
+ case event_type
224
+ when "login_success"
225
+ tags << "trust:auth:login_success"
226
+ when "login_failed"
227
+ tags << "risk:suspicious:auth:login_failed"
228
+ when "permission_denied"
229
+ tags << "risk:suspicious:authz:permission_denied"
230
+ when "mfa_failed"
231
+ tags << "risk:suspicious:auth:mfa_failed"
232
+ when "account_locked"
233
+ tags << "risk:malicious:auth:account_locked"
234
+ when "password_reset"
235
+ tags << "trust:auth:password_reset"
236
+ when "account_unlock", "account_unlocked"
237
+ tags << "trust:auth:account_unlocked"
238
+ when "mfa_result"
239
+ if %w[fail failed].include?(outcome)
240
+ tags << "risk:suspicious:auth:mfa_failed"
241
+ elsif %w[success pass].include?(outcome)
242
+ tags << "trust:auth:mfa_success"
243
+ end
244
+ end
245
+ tags << "security:outcome:#{outcome}" unless outcome.nil? || outcome.empty?
246
+ tags.compact.uniq
247
+ end
248
+
249
+ def merge_metadata(*candidates)
250
+ merged = {}
251
+ candidates.compact.each do |item|
252
+ hash = item.respond_to?(:to_h) ? item.to_h : {}
253
+ hash.each { |k, v| merged[k.to_sym] = v }
254
+ end
255
+ merged
256
+ end
257
+
258
+ def normalize_token(value)
259
+ return nil if value.nil?
260
+ value.to_s.strip.downcase.gsub(/[^a-z0-9_-]+/, "_").gsub(/\A_+|_+\z/, "")
261
+ end
262
+
90
263
  def push_to_buffer(type, entry)
91
264
  key = case type
92
265
  when :request then Reputable.configuration.request_buffer_key
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module Reputable
8
+ # Bot Signals — verify the signed ticket the widget (or the check endpoint)
9
+ # hands to your form. A ticket is a short-lived signed JWT carrying the
10
+ # visitor's signal report (network evidence, score, and whether a CAPTCHA was
11
+ # solved). Verify it offline against the published JWKS (no network call),
12
+ # with the hosted introspection endpoint as a fallback for environments that
13
+ # cannot fetch/cache the JWKS.
14
+ #
15
+ # What you do with the report is up to you: the "outcome" field is an
16
+ # advisory assessment derived from YOUR key's thresholds.
17
+ #
18
+ # @example Require a ticket on login
19
+ # report = Reputable.verify_ticket(params["reputable-ticket"], customer_id: "cus_123")
20
+ # if report && report["outcome"] == "allow"
21
+ # # proceed; report["challengePassed"] tells you if a CAPTCHA was solved
22
+ # else
23
+ # # reject / re-challenge — your call
24
+ # end
25
+ module Verify
26
+ module_function
27
+
28
+ # Verify a ticket and return its signal report hash (keys include
29
+ # "outcome", "challengePassed", "proxyScore", "rating", "signals", ...),
30
+ # or nil if invalid.
31
+ #
32
+ # Verification is offline-first (JWKS); if that yields nothing it falls back
33
+ # to the hosted introspection endpoint. When +session_ref+ is given it must
34
+ # match the ticket's subject.
35
+ def verify_ticket(ticket, **options)
36
+ claims = verify_ticket_claims(ticket, **options)
37
+ claims && (claims["report"] || claims["result"] || claims)
38
+ rescue StandardError => e
39
+ Reputable.logger&.warn "Reputable: verify_ticket failed: #{e.message}"
40
+ nil
41
+ end
42
+
43
+ # Verify a ticket and return the complete signed claims. Set
44
+ # +network_fallback: false+ in latency-sensitive request paths to prohibit
45
+ # both JWKS fetching and hosted introspection.
46
+ def verify_ticket_claims(ticket, customer_id: nil, session_ref: nil, jwks: nil,
47
+ network_fallback: true, expected_key_id: nil,
48
+ expected_mode: nil, expected_return_origin: nil)
49
+ return nil if blank?(ticket)
50
+
51
+ offline_jwks = jwks || Reputable.configuration.measurement_result_jwks
52
+ return nil if !network_fallback && offline_jwks.nil?
53
+
54
+ claims = Measurement.verify_result_token(
55
+ ticket,
56
+ jwks: offline_jwks,
57
+ expected_customer_id: customer_id
58
+ )
59
+ claims ||= introspect(ticket, customer_id: customer_id, session_ref: session_ref) if network_fallback
60
+ return nil unless claims
61
+
62
+ return nil unless claim_matches?(claims, "sessionRef", session_ref, alternate: "sub")
63
+ return nil unless claim_matches?(claims, "keyId", expected_key_id)
64
+ return nil unless claim_matches?(claims, "mode", expected_mode)
65
+ return nil unless return_origin_matches?(claims["returnOrigin"], expected_return_origin)
66
+
67
+ claims
68
+ rescue StandardError => e
69
+ Reputable.logger&.warn "Reputable: verify_ticket_claims failed: #{e.message}"
70
+ nil
71
+ end
72
+
73
+ # Legacy alias from the proof era.
74
+ def verify_proof(proof, **kwargs)
75
+ verify_ticket(proof, **kwargs)
76
+ end
77
+
78
+ # Convenience predicate: did this ticket clear a CAPTCHA challenge?
79
+ def challenge_passed?(ticket, **kwargs)
80
+ report = verify_ticket(ticket, **kwargs)
81
+ return false unless report
82
+
83
+ !!(report["challengePassed"] || report[:challengePassed])
84
+ end
85
+
86
+ # Introspection fallback: POST the ticket to the hosted service, which
87
+ # verifies it server-side and returns the report. Always treat a non-valid
88
+ # response as a rejection.
89
+ def introspect(ticket, customer_id: nil, session_ref: nil)
90
+ base = Measurement.measurement_base_url
91
+ uri = URI("#{base}/v1/signals/introspect")
92
+ payload = { ticket: ticket, customerId: customer_id, sessionRef: session_ref }.compact
93
+
94
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
95
+ open_timeout: Reputable.configuration.connect_timeout,
96
+ read_timeout: Reputable.configuration.read_timeout) do |http|
97
+ request = Net::HTTP::Post.new(uri.request_uri, "Content-Type" => "application/json")
98
+ request.body = JSON.generate(payload)
99
+ http.request(request)
100
+ end
101
+ return nil unless response.is_a?(Net::HTTPSuccess)
102
+
103
+ data = JSON.parse(response.body)
104
+ return nil unless data["valid"]
105
+
106
+ { "report" => data["report"] || data["result"], "sessionRef" => data["sessionRef"], "customerId" => data["customerId"] }
107
+ rescue StandardError => e
108
+ Reputable.logger&.warn "Reputable: ticket introspection failed: #{e.message}"
109
+ nil
110
+ end
111
+
112
+ def present?(value)
113
+ !blank?(value)
114
+ end
115
+
116
+ def blank?(value)
117
+ value.nil? || (value.respond_to?(:empty?) && value.empty?)
118
+ end
119
+
120
+ def claim_matches?(claims, key, expected, alternate: nil)
121
+ return true unless present?(expected)
122
+
123
+ values = [claims[key], alternate && claims[alternate]].compact
124
+ values.any? && values.all? { |value| value == expected }
125
+ end
126
+
127
+ def return_origin_matches?(actual, expected)
128
+ return true unless present?(expected)
129
+
130
+ normalized_actual = normalize_origin(actual)
131
+ normalized_expected = normalize_origin(expected)
132
+ normalized_actual && normalized_expected && normalized_actual == normalized_expected
133
+ end
134
+
135
+ def normalize_origin(value)
136
+ uri = URI(value.to_s)
137
+ return nil unless %w[http https].include?(uri.scheme) && present?(uri.host)
138
+
139
+ default_port = (uri.scheme == "https" ? 443 : 80)
140
+ port = uri.port == default_port ? "" : ":#{uri.port}"
141
+ "#{uri.scheme}://#{uri.host.downcase}#{port}"
142
+ rescue URI::InvalidURIError
143
+ nil
144
+ end
145
+ end
146
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Reputable
4
- VERSION = "0.1.25"
4
+ VERSION = "0.1.26"
5
5
  end
data/lib/reputable.rb CHANGED
@@ -7,6 +7,9 @@ require_relative "reputable/configuration"
7
7
  require_relative "reputable/connection"
8
8
  require_relative "reputable/tracker"
9
9
  require_relative "reputable/reputation"
10
+ require_relative "reputable/measurement"
11
+ require_relative "reputable/verify"
12
+ require_relative "reputable/signals"
10
13
  require_relative "reputable/blocked_page"
11
14
  require_relative "reputable/middleware"
12
15
 
@@ -112,6 +115,14 @@ module Reputable
112
115
  Tracker.track_request_async(ip: ip, path: path, **options)
113
116
  end
114
117
 
118
+ def track_security_event(**kwargs)
119
+ Tracker.track_security_event(**kwargs)
120
+ end
121
+
122
+ def track_security_event_async(**kwargs)
123
+ Tracker.track_security_event_async(**kwargs)
124
+ end
125
+
115
126
  # Delegate reputation methods to Reputation module
116
127
  def apply_reputation(entity_type:, entity_id:, status:, **options)
117
128
  Reputation.apply(entity_type: entity_type, entity_id: entity_id, status: status, **options)
@@ -187,6 +198,64 @@ module Reputable
187
198
  Reputation.ignore_asn(asn, reason: reason, ttl: ttl, **metadata)
188
199
  end
189
200
 
201
+ def measurement_token(**kwargs)
202
+ Measurement.measurement_token(**kwargs)
203
+ end
204
+
205
+ def measurement_collect_url(token)
206
+ Measurement.collect_url(token)
207
+ end
208
+
209
+ def measurement_redirect_url(token)
210
+ Measurement.redirect_url(token)
211
+ end
212
+
213
+ def verify_measurement_result_token(token, **kwargs)
214
+ Measurement.verify_result_token(token, **kwargs)
215
+ end
216
+
217
+ def verify_measurement_result(token, **kwargs)
218
+ Measurement.verify_result(token, **kwargs)
219
+ end
220
+
221
+ # Bot Signals: verify the signed ticket submitted by the widget. Returns
222
+ # the signal report hash (incl. "outcome" and "challengePassed") or nil.
223
+ def verify_ticket(ticket, **kwargs)
224
+ Verify.verify_ticket(ticket, **kwargs)
225
+ end
226
+
227
+ # Bot Signals: verify and return all signed ticket claims. This is useful
228
+ # when an application must bind a route pass to key, mode, session, or
229
+ # return origin rather than reading only the report.
230
+ def verify_ticket_claims(ticket, **kwargs)
231
+ Verify.verify_ticket_claims(ticket, **kwargs)
232
+ end
233
+
234
+ # Legacy alias from the proof era.
235
+ def verify_proof(proof, **kwargs)
236
+ Verify.verify_ticket(proof, **kwargs)
237
+ end
238
+
239
+ # Did the ticket clear a CAPTCHA challenge? Convenience predicate.
240
+ def ticket_challenge_passed?(ticket, **kwargs)
241
+ Verify.challenge_passed?(ticket, **kwargs)
242
+ end
243
+
244
+ # Legacy alias from the proof era.
245
+ def proof_challenge_passed?(proof, **kwargs)
246
+ Verify.challenge_passed?(proof, **kwargs)
247
+ end
248
+
249
+ # Bot Signals server API: signal report for an end user's request.
250
+ def signals_check(**kwargs)
251
+ Signals.check(**kwargs)
252
+ end
253
+
254
+ # Bot Signals redirect-flow URL (publishable key + registered return URL).
255
+ def signals_redirect_url(**kwargs)
256
+ Signals.redirect_url(**kwargs)
257
+ end
258
+
190
259
  # Generate a signed verification URL
191
260
  # @param return_url [String] URL to redirect to after successful verification
192
261
  # @param failure_url [String, nil] URL to redirect to on failure (optional)
data/reputable.gemspec CHANGED
@@ -29,6 +29,7 @@ Gem::Specification.new do |spec|
29
29
 
30
30
  spec.add_dependency "redis", ">= 4.0", "< 6.0"
31
31
  spec.add_dependency "connection_pool", "~> 2.2"
32
+ spec.add_dependency "base64", ">= 0.2", "< 0.4"
32
33
 
33
34
  spec.add_development_dependency "bundler", "~> 2.0"
34
35
  spec.add_development_dependency "rake", "~> 13.0"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: reputable
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.25
4
+ version: 0.1.26
5
5
  platform: ruby
6
6
  authors:
7
7
  - Reputable
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-02-11 00:00:00.000000000 Z
11
+ date: 2026-07-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: redis
@@ -44,6 +44,26 @@ dependencies:
44
44
  - - "~>"
45
45
  - !ruby/object:Gem::Version
46
46
  version: '2.2'
47
+ - !ruby/object:Gem::Dependency
48
+ name: base64
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0.2'
54
+ - - "<"
55
+ - !ruby/object:Gem::Version
56
+ version: '0.4'
57
+ type: :runtime
58
+ prerelease: false
59
+ version_requirements: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: '0.2'
64
+ - - "<"
65
+ - !ruby/object:Gem::Version
66
+ version: '0.4'
47
67
  - !ruby/object:Gem::Dependency
48
68
  name: bundler
49
69
  requirement: !ruby/object:Gem::Requirement
@@ -132,10 +152,13 @@ files:
132
152
  - lib/reputable/blocked_page.rb
133
153
  - lib/reputable/configuration.rb
134
154
  - lib/reputable/connection.rb
155
+ - lib/reputable/measurement.rb
135
156
  - lib/reputable/middleware.rb
136
157
  - lib/reputable/rails.rb
137
158
  - lib/reputable/reputation.rb
159
+ - lib/reputable/signals.rb
138
160
  - lib/reputable/tracker.rb
161
+ - lib/reputable/verify.rb
139
162
  - lib/reputable/version.rb
140
163
  - reputable.gemspec
141
164
  homepage: https://github.com/reputable-click/reputable-rb