google_pay_ruby 1.0.1 → 2.0.0

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: 514316410dc1a39e4fba905f092019bb18c240bdda4e0091eedc32c24ec9760c
4
- data.tar.gz: dc3a7c605555e55a95a3611ce1312a74cb7d8b50bc4d3fd283003619bda585d9
3
+ metadata.gz: 3e6258f7a6dc37c5f6483eecdc7a1b4a3cd47764cc69088677ee3a333b4be379
4
+ data.tar.gz: 6058b7215fe692a90d3de81e5f3445abb808a10e02867e50153315285a524b2c
5
5
  SHA512:
6
- metadata.gz: 0233c81acf3e03655c8d6eb00c47f9f4ceded4612ff2bdfa19219d0c1f64790e79aff2a26eabe9a25899b59ff1486d522658c564d6472d9f63923d54120069b7
7
- data.tar.gz: e7e0d6cb17829999003335d65dd9224a286eadfa84e0728b49e04637287d431c94ee95d738ba5329238fc561f7b1060f14d88ab9fb18f88ede9eaf87b185ea42
6
+ metadata.gz: fbb21c17fa7690351a99a2e089bcde9b64c96ed2d75629a15338955e4915a93c145514570750c3b2ebbd7201fc6efa6f3352c1427681dbc6ca0bc578a234fa68
7
+ data.tar.gz: ecc866798e1e3a802ca06e613b85fe9c1f367223c74a9a150c5fb506e2ae30de0a4913b8e646fd0e587a0ccf16358b286ac8d55c6ec4325e18513ed08eb1d994
data/CHANGELOG.md CHANGED
@@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [2.0.0] - 2025-06-18
9
+
10
+ ### Added
11
+ - **Signature Verification**: Full Google Pay ECv2 PaymentMethodToken signature verification per [Google Pay cryptography spec](https://developers.google.com/pay/api/web/guides/resources/payment-data-cryptography)
12
+ - Fetch Google root signing keys from production or test URLs
13
+ - Verify intermediate signing key signature against non-expired root keys
14
+ - Verify intermediate signing key expiration
15
+ - Verify message signature using intermediate signing key
16
+ - **Message Expiration Check**: Verify `messageExpiration` field after decryption
17
+ - New `SignatureVerifier` class encapsulating all verification logic
18
+ - New initialization options for `GooglePaymentMethodTokenContext`:
19
+ - `:recipient_id` — required when signature verification is enabled (e.g. `"gateway:<gatewayId>"` or `"merchant:<merchantId>"`)
20
+ - `:root_signing_keys` — optional pre-fetched root keys (fetched automatically if nil)
21
+ - `:test` — use Google's test keys URL (default: false)
22
+ - `:verify_signature` — enable/disable signature verification (default: true)
23
+ - `:verify_expiration` — enable/disable message expiration check (default: true)
24
+
25
+ ### Breaking Changes
26
+ - `GooglePaymentMethodTokenContext.new` now requires `:recipient_id` by default (signature verification is enabled by default)
27
+ - To preserve v1 behavior, pass `verify_signature: false, verify_expiration: false`
28
+
8
29
  ## [1.0.1] - 2026-02-05
9
30
 
10
31
  ### Added
data/README.md CHANGED
@@ -4,7 +4,9 @@ A Ruby utility for securely decrypting Google Pay PaymentMethodTokens using the
4
4
 
5
5
  ## Features
6
6
 
7
+ - **ECv2 Signature Verification**: Full Google Pay ECv2 PaymentMethodToken signature verification per [Google Pay cryptography spec](https://developers.google.com/pay/api/web/guides/resources/payment-data-cryptography)
7
8
  - **Google Pay PaymentMethodToken Decryption**: Securely decrypt user-authorized Google Pay transaction tokens using easy-to-use interfaces
9
+ - **Message Expiration Verification**: Automatically verify that decrypted messages haven't expired
8
10
  - **Key Rotation Support**: Handle multiple private keys simultaneously to support seamless key rotation without missing payments
9
11
  - **Pure Ruby Implementation**: No external dependencies beyond OpenSSL (included in Ruby standard library)
10
12
  - **ECv2 Protocol Support**: Implements Google Pay's ECv2 encryption protocol
@@ -41,7 +43,7 @@ Follow [Google Pay API guides](https://developers.google.com/pay/api) for your p
41
43
 
42
44
  ## Usage
43
45
 
44
- ### Basic Example
46
+ ### Basic Example (with full verification)
45
47
 
46
48
  ```ruby
47
49
  require 'google_pay_ruby'
@@ -49,24 +51,29 @@ require 'google_pay_ruby'
49
51
  # Load your private key (from file, KMS, secrets manager, etc.)
50
52
  private_key_pem = File.read('path/to/your/private_key.pem')
51
53
 
52
- # Create the decryption context
54
+ # Create the decryption context with signature verification
53
55
  context = GooglePayRuby::GooglePaymentMethodTokenContext.new(
54
56
  merchants: [
55
57
  {
56
58
  identifier: 'my-merchant-identifier', # Optional, for debugging
57
59
  private_key_pem: private_key_pem
58
60
  }
59
- ]
61
+ ],
62
+ recipient_id: 'gateway:your-gateway-id' # or 'merchant:your-merchant-id'
60
63
  )
61
64
 
62
65
  # Get the token from Google Pay API response
63
66
  token = {
64
67
  'protocolVersion' => 'ECv2',
65
68
  'signature' => '...',
69
+ 'intermediateSigningKey' => {
70
+ 'signedKey' => '{"keyValue":"...","keyExpiration":"..."}',
71
+ 'signatures' => ['...']
72
+ },
66
73
  'signedMessage' => '{"encryptedMessage":"...","ephemeralPublicKey":"...","tag":"..."}'
67
74
  }
68
75
 
69
- # Decrypt the token
76
+ # Decrypt the token (signature verification + expiration check happen automatically)
70
77
  begin
71
78
  decrypted_data = context.decrypt(token)
72
79
 
@@ -93,13 +100,39 @@ context = GooglePayRuby::GooglePaymentMethodTokenContext.new(
93
100
  identifier: 'previous-key',
94
101
  private_key_pem: File.read('previous_key.pem')
95
102
  }
96
- ]
103
+ ],
104
+ recipient_id: 'gateway:your-gateway-id'
97
105
  )
98
106
 
99
107
  # The library will try each key until decryption succeeds
100
108
  decrypted_data = context.decrypt(token)
101
109
  ```
102
110
 
111
+ ### Configuration Options
112
+
113
+ ```ruby
114
+ context = GooglePayRuby::GooglePaymentMethodTokenContext.new(
115
+ merchants: [...],
116
+ recipient_id: 'gateway:your-gateway-id', # Required when verify_signature is true
117
+ root_signing_keys: nil, # Pre-fetched keys, or nil to fetch from Google
118
+ test: false, # Use Google's test keys URL
119
+ verify_signature: true, # Enable/disable signature verification
120
+ verify_expiration: true # Enable/disable message expiration check
121
+ )
122
+ ```
123
+
124
+ ### Decryption Only (no verification)
125
+
126
+ To preserve v1 behavior without signature verification:
127
+
128
+ ```ruby
129
+ context = GooglePayRuby::GooglePaymentMethodTokenContext.new(
130
+ merchants: [{ private_key_pem: private_key_pem }],
131
+ verify_signature: false,
132
+ verify_expiration: false
133
+ )
134
+ ```
135
+
103
136
  ### Decrypted Data Structure
104
137
 
105
138
  The decrypted payment data contains:
@@ -143,7 +176,13 @@ end
143
176
 
144
177
  ## Development
145
178
 
146
- After checking out the repo, run `bundle install` to install dependencies. Then, run `ruby test/test_decrypt.rb` to run the tests.
179
+ After checking out the repo, run `bundle install` to install dependencies. Then, run the tests:
180
+
181
+ ```bash
182
+ ruby test/test_signature_verifier.rb
183
+ ruby test/test_context_with_verification.rb
184
+ ruby test/test_decrypt.rb
185
+ ```
147
186
 
148
187
  ## Contributing
149
188
 
@@ -4,19 +4,51 @@ module GooglePayRuby
4
4
  class GooglePaymentMethodTokenContext
5
5
  attr_reader :merchants
6
6
 
7
+ # @param options [Hash]
8
+ # @option options [Array<Hash>] :merchants List of merchant configs with :private_key_pem and optional :identifier
9
+ # @option options [String] :recipient_id The recipient ID for message signature verification
10
+ # (e.g. "merchant:<merchantId>" or "gateway:<gatewayId>"). Required when verify_signature is true.
11
+ # @option options [Array<Hash>, nil] :root_signing_keys Pre-fetched Google root signing keys (ECv2 only).
12
+ # If nil, fetched automatically from Google's public URL.
13
+ # @option options [Boolean] :test Whether to use Google's test keys URL (default: false)
14
+ # @option options [Boolean] :verify_signature Whether to verify token signatures before decryption (default: true)
15
+ # @option options [Boolean] :verify_expiration Whether to verify messageExpiration after decryption (default: true)
16
+ # @option options [String, nil] :gateway_merchant_id Expected gatewayMerchantId to verify in the decrypted payload.
17
+ # When provided, the decrypted paymentMethodDetails.gatewayMerchantId must match this value.
18
+ # @option options [Boolean] :verify_merchant_id Whether to verify gatewayMerchantId after decryption (default: true if gateway_merchant_id is provided)
7
19
  def initialize(options)
8
20
  @merchants = options[:merchants] || []
9
-
21
+ @recipient_id = options[:recipient_id]
22
+ @root_signing_keys = options[:root_signing_keys]
23
+ @gateway_merchant_id = options[:gateway_merchant_id]
24
+ @test = options.fetch(:test, false)
25
+ @verify_signature = options.fetch(:verify_signature, true)
26
+ @verify_expiration = options.fetch(:verify_expiration, true)
27
+ @verify_merchant_id = options.fetch(:verify_merchant_id, !@gateway_merchant_id.nil?)
28
+
10
29
  if @merchants.empty?
11
30
  raise GooglePaymentDecryptionError.new(
12
31
  'No merchant configuration provided for decryption context.'
13
32
  )
14
33
  end
15
-
34
+
35
+ if @verify_signature && (@recipient_id.nil? || @recipient_id.empty?)
36
+ raise ArgumentError, ':recipient_id is required when signature verification is enabled'
37
+ end
38
+
16
39
  validate_merchant_configurations!
17
40
  end
18
41
 
42
+ # @param token [Hash, String] The Google Pay payment method token.
43
+ # Can be a Hash (already parsed) or a JSON String (will be parsed internally).
44
+ # When a String is provided, the raw JSON is preserved for signature verification
45
+ # to handle unicode escapes (e.g. \u003d) that JSON.parse would decode.
19
46
  def decrypt(token)
47
+ if token.is_a?(String)
48
+ raw_token_json = token
49
+ token = JSON.parse(token)
50
+ end
51
+
20
52
  protocol_version = token['protocolVersion'] || token[:protocolVersion]
21
53
  unless protocol_version == 'ECv2'
22
54
  raise GooglePaymentDecryptionError.new(
@@ -24,33 +56,97 @@ module GooglePayRuby
24
56
  )
25
57
  end
26
58
 
59
+ # Steps 1-4: Verify signatures before decryption
60
+ if @verify_signature
61
+ verifier = SignatureVerifier.new(
62
+ root_signing_keys: @root_signing_keys,
63
+ recipient_id: @recipient_id,
64
+ test: @test
65
+ )
66
+ verifier.verify!(token, raw_token_json: raw_token_json)
67
+ end
68
+
69
+ # Step 5: Decrypt the payload
27
70
  errors = []
28
71
  signed_message = token['signedMessage'] || token[:signedMessage]
29
72
 
73
+ decrypted_data = nil
30
74
  @merchants.each do |merchant|
31
75
  begin
32
76
  strategy = EcV2DecryptionStrategy.new(merchant[:private_key_pem])
33
- return strategy.decrypt(signed_message)
77
+ decrypted_data = strategy.decrypt(signed_message)
78
+ break
34
79
  rescue StandardError => e
35
80
  e.define_singleton_method(:merchant_identifier) { merchant[:identifier] }
36
81
  errors << e
37
82
  end
38
83
  end
39
84
 
40
- raise GooglePaymentDecryptionError.new(
41
- 'Failed to decrypt payment data using provided merchant configuration(s).',
42
- errors
43
- )
85
+ unless decrypted_data
86
+ raise GooglePaymentDecryptionError.new(
87
+ 'Failed to decrypt payment data using provided merchant configuration(s).',
88
+ errors
89
+ )
90
+ end
91
+
92
+ # Step 6: Verify message hasn't expired
93
+ if @verify_expiration
94
+ verify_message_expiration!(decrypted_data)
95
+ end
96
+
97
+ # Step 7: Verify gatewayMerchantId matches expected value
98
+ if @verify_merchant_id
99
+ verify_gateway_merchant_id!(decrypted_data)
100
+ end
101
+
102
+ decrypted_data
44
103
  end
45
104
 
46
105
  private
47
106
 
107
+ # Step 7: Verify that the gatewayMerchantId in the decrypted payload matches the expected value.
108
+ # Per spec: "Verify that the gatewayMerchantId matches the ID of the merchant that gave you the payload."
109
+ def verify_gateway_merchant_id!(decrypted_data)
110
+ actual_merchant_id = decrypted_data['gatewayMerchantId']
111
+
112
+ unless actual_merchant_id
113
+ raise GooglePaymentDecryptionError.new(
114
+ 'Decrypted message is missing gatewayMerchantId field'
115
+ )
116
+ end
117
+
118
+ unless actual_merchant_id == @gateway_merchant_id
119
+ raise GooglePaymentDecryptionError.new(
120
+ "gatewayMerchantId mismatch: expected '#{@gateway_merchant_id}', got '#{actual_merchant_id}'"
121
+ )
122
+ end
123
+ end
124
+
125
+ # Step 6: Verify that the current time is less than the messageExpiration field
126
+ def verify_message_expiration!(decrypted_data)
127
+ message_expiration = decrypted_data['messageExpiration']
128
+
129
+ unless message_expiration
130
+ raise GooglePaymentDecryptionError.new(
131
+ 'Decrypted message is missing messageExpiration field'
132
+ )
133
+ end
134
+
135
+ now_ms = (Time.now.to_f * 1000).to_i
136
+
137
+ if message_expiration.to_i <= now_ms
138
+ raise GooglePaymentDecryptionError.new(
139
+ "Decrypted message has expired (messageExpiration: #{message_expiration})"
140
+ )
141
+ end
142
+ end
143
+
48
144
  def validate_merchant_configurations!
49
145
  @merchants.each_with_index do |merchant, index|
50
146
  unless merchant.is_a?(Hash)
51
147
  raise ArgumentError, "Merchant configuration at index #{index} must be a Hash"
52
148
  end
53
-
149
+
54
150
  unless merchant[:private_key_pem]
55
151
  raise ArgumentError, "Merchant configuration at index #{index} must include :private_key_pem"
56
152
  end
@@ -0,0 +1,330 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require 'base64'
5
+ require 'json'
6
+ require 'net/http'
7
+ require 'uri'
8
+
9
+ module GooglePayRuby
10
+ # Implements Google Pay ECv2 signature verification as specified in:
11
+ # https://developers.google.com/pay/api/web/guides/resources/payment-data-cryptography
12
+ #
13
+ # Verification steps:
14
+ # 1. Fetch Google root signing keys
15
+ # 2. Verify intermediate signing key signature against non-expired root keys
16
+ # 3. Verify intermediate signing key hasn't expired
17
+ # 4. Verify message signature using intermediate signing key
18
+ class SignatureVerifier
19
+ SENDER_ID = 'Google'
20
+ PROTOCOL_VERSION = 'ECv2'
21
+
22
+ GOOGLE_ROOT_SIGNING_KEYS_PROD_URL = 'https://payments.developers.google.com/paymentmethodtoken/keys.json'
23
+ GOOGLE_ROOT_SIGNING_KEYS_TEST_URL = 'https://payments.developers.google.com/paymentmethodtoken/test/keys.json'
24
+
25
+ # @param root_signing_keys [Array<Hash>, nil] Pre-fetched root signing keys (ECv2 only).
26
+ # Each hash should have 'keyValue', 'protocolVersion', and optionally 'keyExpiration'.
27
+ # If nil, keys are fetched from Google's public URL.
28
+ # @param recipient_id [String] The recipient ID used in message signature verification.
29
+ # For merchants: "merchant:<merchantId>" (merchantId from Google Pay & Wallet Console).
30
+ # For gateways: "gateway:<gatewayId>".
31
+ # @param test [Boolean] Whether to use Google's test keys URL (default: false).
32
+ def initialize(root_signing_keys: nil, recipient_id:, test: false)
33
+ @root_signing_keys = root_signing_keys
34
+ @recipient_id = recipient_id
35
+ @test = test
36
+ end
37
+
38
+ # Runs all verification steps (1-4) on the given token.
39
+ # Raises GooglePaymentDecryptionError on any verification failure.
40
+ #
41
+ # @param token [Hash] The full Google Pay payment method token (parsed)
42
+ # @param raw_token_json [String, nil] The original raw JSON string of the token.
43
+ # When provided, signedKey and signedMessage are extracted from this raw string
44
+ # to preserve the exact byte sequences that Google signed over (e.g. \u003d escapes).
45
+ # JSON.parse decodes \u003d to '=' which changes the signed content and breaks verification.
46
+ # @return [void]
47
+ def verify!(token, raw_token_json: nil)
48
+ protocol_version = token['protocolVersion'] || token[:protocolVersion]
49
+ unless protocol_version == PROTOCOL_VERSION
50
+ raise GooglePaymentDecryptionError.new(
51
+ "Unsupported protocol version: #{protocol_version}. Only ECv2 is supported."
52
+ )
53
+ end
54
+
55
+ intermediate_signing_key = token['intermediateSigningKey'] || token[:intermediateSigningKey]
56
+ unless intermediate_signing_key
57
+ raise GooglePaymentDecryptionError.new('Missing intermediateSigningKey in token')
58
+ end
59
+
60
+ signed_key_json = intermediate_signing_key['signedKey'] || intermediate_signing_key[:signedKey]
61
+ signatures = intermediate_signing_key['signatures'] || intermediate_signing_key[:signatures]
62
+
63
+ unless signed_key_json && signatures
64
+ raise GooglePaymentDecryptionError.new('Missing signedKey or signatures in intermediateSigningKey')
65
+ end
66
+
67
+ # Extract original signedKey and signedMessage from raw JSON if available.
68
+ # This preserves the exact byte sequences (including unicode escapes like \u003d)
69
+ # that Google used when computing signatures.
70
+ if raw_token_json
71
+ raw_signed_key = extract_json_string_value(raw_token_json, 'signedKey')
72
+ raw_signed_message = extract_json_string_value(raw_token_json, 'signedMessage')
73
+ signed_key_json = raw_signed_key if raw_signed_key
74
+ signed_message_for_verify = raw_signed_message
75
+ end
76
+
77
+ # Step 2: Verify intermediate signing key signature against non-expired root keys
78
+ verify_intermediate_signing_key_signature!(signed_key_json, signatures)
79
+
80
+ # Step 3: Verify intermediate signing key hasn't expired
81
+ verify_intermediate_signing_key_expiration!(signed_key_json)
82
+
83
+ # Step 4: Verify message signature using intermediate signing key
84
+ signed_message = signed_message_for_verify || token['signedMessage'] || token[:signedMessage]
85
+ signature = token['signature'] || token[:signature]
86
+
87
+ unless signed_message && signature
88
+ raise GooglePaymentDecryptionError.new('Missing signedMessage or signature in token')
89
+ end
90
+
91
+ parsed_signed_key = JSON.parse(signed_key_json)
92
+ intermediate_key_value = parsed_signed_key['keyValue']
93
+
94
+ verify_message_signature!(signed_message, signature, intermediate_key_value)
95
+ end
96
+
97
+ private
98
+
99
+ # Step 1: Fetch or return cached root signing keys (filtered to ECv2).
100
+ def root_signing_keys
101
+ @root_signing_keys ||= fetch_root_signing_keys
102
+ end
103
+
104
+ def fetch_root_signing_keys
105
+ url = @test ? GOOGLE_ROOT_SIGNING_KEYS_TEST_URL : GOOGLE_ROOT_SIGNING_KEYS_PROD_URL
106
+ uri = URI.parse(url)
107
+
108
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
109
+ http.open_timeout = 10
110
+ http.read_timeout = 10
111
+ http.get(uri.request_uri)
112
+ end
113
+
114
+ unless response.is_a?(Net::HTTPSuccess)
115
+ raise GooglePaymentDecryptionError.new(
116
+ "Failed to fetch Google root signing keys: HTTP #{response.code}"
117
+ )
118
+ end
119
+
120
+ parsed = JSON.parse(response.body)
121
+ keys = parsed['keys'] || []
122
+
123
+ # Filter to ECv2 keys only
124
+ keys.select { |k| k['protocolVersion'] == PROTOCOL_VERSION }
125
+ rescue JSON::ParserError => e
126
+ raise GooglePaymentDecryptionError.new("Failed to parse Google root signing keys: #{e.message}")
127
+ rescue StandardError => e
128
+ raise e if e.is_a?(GooglePaymentDecryptionError)
129
+
130
+ raise GooglePaymentDecryptionError.new("Failed to fetch Google root signing keys: #{e.message}")
131
+ end
132
+
133
+ # Step 2: Verify that at least one signature in intermediateSigningKey.signatures
134
+ # is valid against any non-expired root signing key.
135
+ #
136
+ # Per spec: "iterate over all the signatures in intermediateSigningKey.signatures
137
+ # and try to validate each one with the non-expired Google signing keys in keys.json.
138
+ # If at least one signature validation works, consider the verification complete."
139
+ def verify_intermediate_signing_key_signature!(signed_key_json, signatures)
140
+ signed_bytes = build_signed_bytes_for_intermediate_key(signed_key_json)
141
+ now_ms = (Time.now.to_f * 1000).to_i
142
+
143
+ non_expired_root_keys = root_signing_keys.select do |key|
144
+ expiration = key['keyExpiration']
145
+ expiration.nil? || expiration.to_i > now_ms
146
+ end
147
+
148
+ if non_expired_root_keys.empty?
149
+ raise GooglePaymentDecryptionError.new('No non-expired Google root signing keys available')
150
+ end
151
+
152
+ verified = false
153
+
154
+ signatures.each do |sig_b64|
155
+ break if verified
156
+
157
+ sig_der = Base64.strict_decode64(sig_b64)
158
+
159
+ non_expired_root_keys.each do |root_key|
160
+ begin
161
+ ec_key = build_ec_public_key(root_key['keyValue'])
162
+ if ec_key.dsa_verify_asn1(
163
+ OpenSSL::Digest::SHA256.digest(signed_bytes),
164
+ sig_der
165
+ )
166
+ verified = true
167
+ break
168
+ end
169
+ rescue OpenSSL::PKey::ECError
170
+ next
171
+ end
172
+ end
173
+ end
174
+
175
+ unless verified
176
+ raise GooglePaymentDecryptionError.new(
177
+ 'Could not verify intermediate signing key signature against any non-expired root key'
178
+ )
179
+ end
180
+ end
181
+
182
+ # Step 3: Verify that the intermediate signing key hasn't expired.
183
+ def verify_intermediate_signing_key_expiration!(signed_key_json)
184
+ parsed = JSON.parse(signed_key_json)
185
+ key_expiration = parsed['keyExpiration']
186
+
187
+ unless key_expiration
188
+ raise GooglePaymentDecryptionError.new('intermediateSigningKey.signedKey is missing keyExpiration')
189
+ end
190
+
191
+ now_ms = (Time.now.to_f * 1000).to_i
192
+
193
+ if key_expiration.to_i <= now_ms
194
+ raise GooglePaymentDecryptionError.new(
195
+ "Intermediate signing key has expired (keyExpiration: #{key_expiration})"
196
+ )
197
+ end
198
+ end
199
+
200
+ # Step 4: Verify that the message signature is valid using the intermediate signing key.
201
+ def verify_message_signature!(signed_message, signature_b64, intermediate_key_value_b64)
202
+ signed_bytes = build_signed_bytes_for_message(signed_message)
203
+ sig_der = Base64.strict_decode64(signature_b64)
204
+
205
+ ec_key = build_ec_public_key(intermediate_key_value_b64)
206
+
207
+ valid = ec_key.dsa_verify_asn1(
208
+ OpenSSL::Digest::SHA256.digest(signed_bytes),
209
+ sig_der
210
+ )
211
+
212
+ unless valid
213
+ raise GooglePaymentDecryptionError.new('Message signature verification failed')
214
+ end
215
+ rescue OpenSSL::PKey::ECError => e
216
+ raise GooglePaymentDecryptionError.new("Message signature verification error: #{e.message}")
217
+ end
218
+
219
+ # Construct the byte-string for intermediate signing key signature verification:
220
+ #
221
+ # signedStringForIntermediateSigningKeySignature =
222
+ # length_of_sender_id || sender_id ||
223
+ # length_of_protocol_version || protocol_version ||
224
+ # length_of_signed_key || signed_key
225
+ #
226
+ # Each component is UTF-8 encoded. Lengths are 4 bytes little-endian.
227
+ def build_signed_bytes_for_intermediate_key(signed_key_json)
228
+ sender_id_bytes = SENDER_ID.encode('UTF-8')
229
+ protocol_version_bytes = PROTOCOL_VERSION.encode('UTF-8')
230
+ signed_key_bytes = signed_key_json.encode('UTF-8')
231
+
232
+ [sender_id_bytes.bytesize].pack('V') + sender_id_bytes +
233
+ [protocol_version_bytes.bytesize].pack('V') + protocol_version_bytes +
234
+ [signed_key_bytes.bytesize].pack('V') + signed_key_bytes
235
+ end
236
+
237
+ # Construct the byte-string for message signature verification:
238
+ #
239
+ # signedStringForMessageSignature =
240
+ # length_of_sender_id || sender_id ||
241
+ # length_of_recipient_id || recipient_id ||
242
+ # length_of_protocolVersion || protocolVersion ||
243
+ # length_of_signedMessage || signedMessage
244
+ #
245
+ # Each component is UTF-8 encoded. Lengths are 4 bytes little-endian.
246
+ # Per spec: "don't parse or modify signedMessage"
247
+ def build_signed_bytes_for_message(signed_message)
248
+ sender_id_bytes = SENDER_ID.encode('UTF-8')
249
+ recipient_id_bytes = @recipient_id.encode('UTF-8')
250
+ protocol_version_bytes = PROTOCOL_VERSION.encode('UTF-8')
251
+ signed_message_bytes = signed_message.encode('UTF-8')
252
+
253
+ [sender_id_bytes.bytesize].pack('V') + sender_id_bytes +
254
+ [recipient_id_bytes.bytesize].pack('V') + recipient_id_bytes +
255
+ [protocol_version_bytes.bytesize].pack('V') + protocol_version_bytes +
256
+ [signed_message_bytes.bytesize].pack('V') + signed_message_bytes
257
+ end
258
+
259
+ # Build an OpenSSL EC public key from a base64-encoded SubjectPublicKeyInfo DER value.
260
+ def build_ec_public_key(key_value_b64)
261
+ key_der = Base64.strict_decode64(key_value_b64)
262
+ OpenSSL::PKey::EC.new(key_der)
263
+ end
264
+
265
+ # Extract a JSON string value from raw JSON without decoding unicode escapes.
266
+ # This is critical because Google signs over the exact string content including
267
+ # any \uXXXX escapes. Ruby's JSON.parse decodes \u003d to '=' which changes
268
+ # the bytes and invalidates signatures.
269
+ #
270
+ # For a key like "signedKey", the value in the outer JSON is a JSON-encoded string.
271
+ # We need to extract and unescape the JSON string escapes (\" -> ", \\\\ -> \\)
272
+ # but preserve \uXXXX sequences as-is.
273
+ def extract_json_string_value(raw_json, key)
274
+ # Match "key":"<value>" where value may contain escaped characters
275
+ pattern = /"#{Regexp.escape(key)}"\s*:\s*"/
276
+ match = pattern.match(raw_json)
277
+ return nil unless match
278
+
279
+ start_pos = match.end(0)
280
+ # Walk through the string to find the unescaped closing quote
281
+ pos = start_pos
282
+ result = String.new
283
+ while pos < raw_json.length
284
+ char = raw_json[pos]
285
+ if char == '\\'
286
+ next_char = raw_json[pos + 1]
287
+ case next_char
288
+ when '"'
289
+ result << '"'
290
+ pos += 2
291
+ when '\\'
292
+ result << '\\'
293
+ pos += 2
294
+ when '/'
295
+ result << '/'
296
+ pos += 2
297
+ when 'n'
298
+ result << "\n"
299
+ pos += 2
300
+ when 'r'
301
+ result << "\r"
302
+ pos += 2
303
+ when 't'
304
+ result << "\t"
305
+ pos += 2
306
+ when 'b'
307
+ result << "\b"
308
+ pos += 2
309
+ when 'f'
310
+ result << "\f"
311
+ pos += 2
312
+ when 'u'
313
+ # Preserve \uXXXX as literal characters in the output
314
+ result << raw_json[pos..pos + 5]
315
+ pos += 6
316
+ else
317
+ result << char
318
+ pos += 1
319
+ end
320
+ elsif char == '"'
321
+ break
322
+ else
323
+ result << char
324
+ pos += 1
325
+ end
326
+ end
327
+ result
328
+ end
329
+ end
330
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GooglePayRuby
4
- VERSION = "1.0.1"
4
+ VERSION = "2.0.0"
5
5
  end
@@ -4,6 +4,7 @@ require_relative "google_pay_ruby/version"
4
4
  require_relative "google_pay_ruby/google_payment_method_token_context"
5
5
  require_relative "google_pay_ruby/ec_v2_decryption_strategy"
6
6
  require_relative "google_pay_ruby/google_payment_decryption_error"
7
+ require_relative "google_pay_ruby/signature_verifier"
7
8
 
8
9
  module GooglePayRuby
9
10
  class Error < StandardError; end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: google_pay_ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sahil Gadimbayli
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-02-05 00:00:00.000000000 Z
11
+ date: 2026-02-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rake
@@ -53,6 +53,7 @@ files:
53
53
  - lib/google_pay_ruby/ec_v2_decryption_strategy.rb
54
54
  - lib/google_pay_ruby/google_payment_decryption_error.rb
55
55
  - lib/google_pay_ruby/google_payment_method_token_context.rb
56
+ - lib/google_pay_ruby/signature_verifier.rb
56
57
  - lib/google_pay_ruby/version.rb
57
58
  homepage: https://github.com/gadimbaylisahil/google_pay_ruby
58
59
  licenses: