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
- .audience_matches?(actual, expected) ⇒ Boolean
- .base64url_decode(value) ⇒ Object
- .base64url_encode(value) ⇒ Object
- .blank?(value) ⇒ Boolean
-
.collect_url(_token = nil) ⇒ Object
deprecated
Deprecated.
See Signals.redirect_url.
- .decode_jwt(token) ⇒ Object
- .der_to_jose_signature(der) ⇒ Object
-
.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.
- .ec_public_key_from_jwk(jwk) ⇒ Object
- .escape(value) ⇒ Object
- .fetch_jwks_path(path) ⇒ Object
- .fetch_measurement_jwks ⇒ Object
- .integer_to_fixed_bytes(value, width) ⇒ Object
- .jose_to_der_signature(signature) ⇒ Object
- .measurement_base_url ⇒ Object
-
.measurement_token ⇒ Object
deprecated
Deprecated.
The two-key model replaced customer-minted JWTs.
- .normalize_request_context(value) ⇒ Object
- .present?(value) ⇒ Boolean
- .public_point_from_jwk(jwk) ⇒ Object
-
.redirect_url(_token = nil) ⇒ Object
deprecated
Deprecated.
See Signals.redirect_url.
- .select_key(keys, kid) ⇒ Object
- .sign_es256_jwt(payload, private_jwk) ⇒ Object
- .stringify_keys(value) ⇒ Object
- .verify_es256_signature(jwk, signed_data, jose_signature) ⇒ Object
- .verify_result(token, **kwargs) ⇒ Object
- .verify_result_token(token, jwks: nil, expected_customer_id: nil, audience: nil) ⇒ Object
Class Method Details
.audience_matches?(actual, expected) ⇒ 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
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
See Signals.redirect_url.
34 35 36 |
# File 'lib/reputable/measurement.rb', line 34 def collect_url(_token = nil) raise NotImplementedError, MINTING_REMOVED end |
.decode_jwt(token) ⇒ Object
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.}" nil end |
.fetch_measurement_jwks ⇒ Object
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_url ⇒ Object
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_token ⇒ Object
The two-key model replaced customer-minted JWTs.
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
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
See Signals.redirect_url.
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.}" nil end |