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 +4 -4
- data/CHANGELOG.md +21 -0
- data/README.md +45 -6
- data/lib/google_pay_ruby/google_payment_method_token_context.rb +104 -8
- data/lib/google_pay_ruby/signature_verifier.rb +330 -0
- data/lib/google_pay_ruby/version.rb +1 -1
- data/lib/google_pay_ruby.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3e6258f7a6dc37c5f6483eecdc7a1b4a3cd47764cc69088677ee3a333b4be379
|
|
4
|
+
data.tar.gz: 6058b7215fe692a90d3de81e5f3445abb808a10e02867e50153315285a524b2c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
data/lib/google_pay_ruby.rb
CHANGED
|
@@ -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:
|
|
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-
|
|
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:
|