Module: Reputable::Measurement

Defined in:
lib/reputable/measurement.rb

Overview

Helpers for the hosted Bot Signals API.

This path is independent from the Redis-backed reputation middleware. It verifies signed tickets offline against the published JWKS. The old customer-minted measurement-token flow is gone: servers call the check API with a plain secret key (see Reputable::Signals) and browsers use a publishable key.

Constant Summary collapse

DEFAULT_AUDIENCE =
"reputable-measure"
MINTING_REMOVED =
"Reputable no longer uses customer-minted measurement tokens. " \
"Use your secret key with Reputable::Signals.check (server) or your " \
"publishable key in the browser snippet/redirect URL instead."

Class Method Summary collapse

Class Method Details

.audience_matches?(actual, expected) ⇒ Boolean

Returns:

  • (Boolean)


207
208
209
# File 'lib/reputable/measurement.rb', line 207

def audience_matches?(actual, expected)
  Array(actual).map(&:to_s).include?(expected.to_s)
end

.base64url_decode(value) ⇒ Object



221
222
223
224
# File 'lib/reputable/measurement.rb', line 221

def base64url_decode(value)
  padded = value.to_s + "=" * ((4 - value.to_s.length % 4) % 4)
  Base64.urlsafe_decode64(padded)
end

.base64url_encode(value) ⇒ Object



217
218
219
# File 'lib/reputable/measurement.rb', line 217

def base64url_encode(value)
  Base64.urlsafe_encode64(value).delete("=")
end

.blank?(value) ⇒ Boolean

Returns:

  • (Boolean)


234
235
236
# File 'lib/reputable/measurement.rb', line 234

def blank?(value)
  value.nil? || (value.respond_to?(:empty?) && value.empty?)
end

.collect_url(_token = nil) ⇒ Object

Deprecated.

Raises:

  • (NotImplementedError)


34
35
36
# File 'lib/reputable/measurement.rb', line 34

def collect_url(_token = nil)
  raise NotImplementedError, MINTING_REMOVED
end

.decode_jwt(token) ⇒ Object

Raises:

  • (ArgumentError)


125
126
127
128
129
130
131
132
# File 'lib/reputable/measurement.rb', line 125

def decode_jwt(token)
  parts = token.to_s.split(".")
  raise ArgumentError, "invalid JWT" unless parts.length == 3

  header = JSON.parse(base64url_decode(parts[0]))
  payload = JSON.parse(base64url_decode(parts[1]))
  [header, payload, "#{parts[0]}.#{parts[1]}", base64url_decode(parts[2])]
end

.der_to_jose_signature(der) ⇒ Object



177
178
179
180
181
182
# File 'lib/reputable/measurement.rb', line 177

def der_to_jose_signature(der)
  sequence = OpenSSL::ASN1.decode(der)
  r = integer_to_fixed_bytes(sequence.value[0].value.to_i, 32)
  s = integer_to_fixed_bytes(sequence.value[1].value.to_i, 32)
  "#{r}#{s}"
end

.ec_private_key_from_jwk(jwk) ⇒ Object

Keys are built from DER rather than assigned via key.private_key= / key.public_key=, which raise "pkeys are immutable" on OpenSSL 3.



145
146
147
148
149
150
151
152
153
154
155
156
157
# File 'lib/reputable/measurement.rb', line 145

def ec_private_key_from_jwk(jwk)
  d = base64url_decode(jwk.fetch("d"))
  asn1 = OpenSSL::ASN1::Sequence([
    OpenSSL::ASN1::Integer(1),
    OpenSSL::ASN1::OctetString(d.rjust(32, "\x00")),
    OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::ObjectId("prime256v1")], 0, :CONTEXT_SPECIFIC),
    OpenSSL::ASN1::ASN1Data.new(
      [OpenSSL::ASN1::BitString(public_point_from_jwk(jwk).to_octet_string(:uncompressed))],
      1, :CONTEXT_SPECIFIC
    )
  ])
  OpenSSL::PKey::EC.new(asn1.to_der)
end

.ec_public_key_from_jwk(jwk) ⇒ Object



159
160
161
162
163
164
165
166
167
168
# File 'lib/reputable/measurement.rb', line 159

def ec_public_key_from_jwk(jwk)
  asn1 = OpenSSL::ASN1::Sequence([
    OpenSSL::ASN1::Sequence([
      OpenSSL::ASN1::ObjectId("id-ecPublicKey"),
      OpenSSL::ASN1::ObjectId("prime256v1")
    ]),
    OpenSSL::ASN1::BitString(public_point_from_jwk(jwk).to_octet_string(:uncompressed))
  ])
  OpenSSL::PKey::EC.new(asn1.to_der)
end

.escape(value) ⇒ Object



226
227
228
# File 'lib/reputable/measurement.rb', line 226

def escape(value)
  URI.encode_www_form_component(value.to_s)
end

.fetch_jwks_path(path) ⇒ Object



84
85
86
87
88
89
90
91
92
93
94
95
96
97
# File 'lib/reputable/measurement.rb', line 84

def fetch_jwks_path(path)
  uri = URI("#{measurement_base_url}#{path}")
  response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
                                                 open_timeout: Reputable.configuration.connect_timeout,
                                                 read_timeout: Reputable.configuration.read_timeout) do |http|
    http.get(uri.request_uri)
  end
  return nil unless response.is_a?(Net::HTTPSuccess)

  JSON.parse(response.body)
rescue StandardError => e
  Reputable.logger&.warn "Reputable: Failed to fetch JWKS from #{path}: #{e.message}"
  nil
end

.fetch_measurement_jwksObject



80
81
82
# File 'lib/reputable/measurement.rb', line 80

def fetch_measurement_jwks
  fetch_jwks_path("/v1/signals/jwks") || fetch_jwks_path("/v1/verify/jwks")
end

.integer_to_fixed_bytes(value, width) ⇒ Object



193
194
195
196
197
198
# File 'lib/reputable/measurement.rb', line 193

def integer_to_fixed_bytes(value, width)
  hex = value.to_s(16)
  hex = "0#{hex}" if hex.length.odd?
  bytes = [hex].pack("H*")
  bytes.rjust(width, "\x00")[-width, width]
end

.jose_to_der_signature(signature) ⇒ Object



184
185
186
187
188
189
190
191
# File 'lib/reputable/measurement.rb', line 184

def jose_to_der_signature(signature)
  r = OpenSSL::BN.new(signature[0, 32], 2)
  s = OpenSSL::BN.new(signature[32, 32], 2)
  OpenSSL::ASN1::Sequence([
    OpenSSL::ASN1::Integer(r),
    OpenSSL::ASN1::Integer(s)
  ]).to_der
end

.measurement_base_urlObject



76
77
78
# File 'lib/reputable/measurement.rb', line 76

def measurement_base_url
  (Reputable.configuration.measurement_base_url || Reputable.configuration.base_url).to_s.chomp("/")
end

.measurement_tokenObject

Deprecated.

The two-key model replaced customer-minted JWTs.

Raises:

  • (NotImplementedError)


29
30
31
# File 'lib/reputable/measurement.rb', line 29

def measurement_token(**)
  raise NotImplementedError, MINTING_REMOVED
end

.normalize_request_context(value) ⇒ Object



99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
# File 'lib/reputable/measurement.rb', line 99

def normalize_request_context(value)
  raw = stringify_keys(value || {})
  {
    scheme: raw["scheme"],
    host: raw["host"],
    method: raw["method"]&.upcase,
    path: raw["path"],
    query: raw["query"],
    url: raw["url"],
    referrer: raw["referrer"] || raw["referer"],
    pageTitle: raw["pageTitle"] || raw["page_title"],
    pageType: raw["pageType"] || raw["page_type"],
    actionType: raw["actionType"] || raw["action_type"]
  }.compact
end

.present?(value) ⇒ Boolean

Returns:

  • (Boolean)


230
231
232
# File 'lib/reputable/measurement.rb', line 230

def present?(value)
  !blank?(value)
end

.public_point_from_jwk(jwk) ⇒ Object



170
171
172
173
174
175
# File 'lib/reputable/measurement.rb', line 170

def public_point_from_jwk(jwk)
  group = OpenSSL::PKey::EC::Group.new("prime256v1")
  x = base64url_decode(jwk.fetch("x"))
  y = base64url_decode(jwk.fetch("y"))
  OpenSSL::PKey::EC::Point.new(group, OpenSSL::BN.new("\x04#{x}#{y}", 2))
end

.redirect_url(_token = nil) ⇒ Object

Deprecated.

Raises:

  • (NotImplementedError)


39
40
41
# File 'lib/reputable/measurement.rb', line 39

def redirect_url(_token = nil)
  raise NotImplementedError, MINTING_REMOVED
end

.select_key(keys, kid) ⇒ Object



200
201
202
203
204
205
# File 'lib/reputable/measurement.rb', line 200

def select_key(keys, kid)
  keys = keys.map { |key| stringify_keys(key) }
  return keys.first if blank?(kid) && keys.length == 1

  keys.find { |key| key["kid"] == kid }
end

.sign_es256_jwt(payload, private_jwk) ⇒ Object



115
116
117
118
119
120
121
122
123
# File 'lib/reputable/measurement.rb', line 115

def sign_es256_jwt(payload, private_jwk)
  jwk = stringify_keys(private_jwk)
  header = { alg: "ES256", typ: "JWT" }
  header[:kid] = jwk["kid"] if present?(jwk["kid"])

  signed_data = "#{base64url_encode(JSON.generate(header))}.#{base64url_encode(JSON.generate(payload))}"
  der_signature = ec_private_key_from_jwk(jwk).dsa_sign_asn1(OpenSSL::Digest::SHA256.digest(signed_data))
  "#{signed_data}.#{base64url_encode(der_to_jose_signature(der_signature))}"
end

.stringify_keys(value) ⇒ Object



211
212
213
214
215
# File 'lib/reputable/measurement.rb', line 211

def stringify_keys(value)
  return value unless value.is_a?(Hash)

  value.each_with_object({}) { |(key, val), out| out[key.to_s] = val }
end

.verify_es256_signature(jwk, signed_data, jose_signature) ⇒ Object



134
135
136
137
138
139
140
141
# File 'lib/reputable/measurement.rb', line 134

def verify_es256_signature(jwk, signed_data, jose_signature)
  return false unless jose_signature.bytesize == 64

  digest = OpenSSL::Digest::SHA256.digest(signed_data)
  ec_public_key_from_jwk(stringify_keys(jwk)).dsa_verify_asn1(digest, jose_to_der_signature(jose_signature))
rescue StandardError
  false
end

.verify_result(token, **kwargs) ⇒ Object



71
72
73
74
# File 'lib/reputable/measurement.rb', line 71

def verify_result(token, **kwargs)
  claims = verify_result_token(token, **kwargs)
  claims && (claims["report"] || claims["result"])
end

.verify_result_token(token, jwks: nil, expected_customer_id: nil, audience: nil) ⇒ Object



43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
# File 'lib/reputable/measurement.rb', line 43

def verify_result_token(token, jwks: nil, expected_customer_id: nil, audience: nil)
  return nil if blank?(token)

  jwks ||= Reputable.configuration.measurement_result_jwks || fetch_measurement_jwks
  keys = Array(jwks.is_a?(Hash) ? jwks["keys"] || jwks[:keys] : jwks)
  return nil if keys.empty?

  header, payload, signed_data, signature = decode_jwt(token)
  return nil unless header["alg"] == "ES256"

  key = select_key(keys, header["kid"])
  return nil unless key && verify_es256_signature(key, signed_data, signature)

  now = Time.now.to_i
  return nil if payload["exp"] && now >= payload["exp"].to_i
  return nil if payload["nbf"] && now < payload["nbf"].to_i

  expected_customer_id ||= Reputable.configuration.measurement_customer_id
  audience ||= expected_customer_id
  return nil if present?(expected_customer_id) && payload["customerId"] != expected_customer_id
  return nil if present?(audience) && !audience_matches?(payload["aud"], audience)

  payload
rescue StandardError => e
  Reputable.logger&.warn "Reputable: Failed to verify measurement result: #{e.message}"
  nil
end