Skip to content

Commit

Permalink
[spreedly#18] Add support for ECv2 google pay tokens
Browse files Browse the repository at this point in the history
Co-authored-by: Miriam Lauter <lauter.miriam@gmail.com>
Co-authored-by: Spencer Alan <hello@spenceralan.com>
  • Loading branch information
3 people committed Oct 8, 2020
1 parent f8370b7 commit ac254a5
Show file tree
Hide file tree
Showing 12 changed files with 282 additions and 47 deletions.
112 changes: 101 additions & 11 deletions lib/r2d2/google_pay_token.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,37 @@ module R2D2
class GooglePayToken
include Util

attr_reader :protocol_version, :recipient_id, :verification_keys, :signature, :signed_message
attr_reader :protocol_version, :recipient_id, :raw_verification_keys, :signature, :signed_message, :intermediate_signing_key

def initialize(token_attrs, recipient_id:, verification_keys:)
@protocol_version = token_attrs['protocolVersion']
@recipient_id = recipient_id
@verification_keys = verification_keys
@raw_verification_keys = verification_keys
@signature = token_attrs['signature']
@signed_message = token_attrs['signedMessage']

# ECv2 only
@intermediate_signing_key = token_attrs['intermediateSigningKey'] || '{}'
end

def decrypt(private_key_pem)
verified = verify_and_parse_message

private_key = OpenSSL::PKey::EC.new(private_key_pem)
shared_secret = generate_shared_secret(private_key, verified['ephemeralPublicKey'])
hkdf_keys = derive_hkdf_keys(verified['ephemeralPublicKey'], shared_secret, 'Google')

hkdf_keys_length_bytes = protocol_version == 'ECv2' ? 32 : 16
hkdf_keys = derive_hkdf_keys(verified['ephemeralPublicKey'], shared_secret, 'Google', hkdf_keys_length_bytes)
verify_mac(hkdf_keys[:mac_key], verified['encryptedMessage'], verified['tag'])

cipher_key_length_bits = protocol_version == 'ECv2' ? 256 : 128
decrypted = JSON.parse(
decrypt_message(verified['encryptedMessage'], hkdf_keys[:symmetric_encryption_key])
decrypt_message(verified['encryptedMessage'], hkdf_keys[:symmetric_encryption_key], cipher_key_length_bits)
)

expired = decrypted['messageExpiration'].to_f / 1000.0 <= Time.now.to_f
cur_millis = (Time.now.to_f * 1000).floor
expired = decrypted['messageExpiration'].to_i <= cur_millis

raise MessageExpiredError if expired

decrypted
Expand All @@ -33,22 +41,104 @@ def decrypt(private_key_pem)
private

def verify_and_parse_message
digest = OpenSSL::Digest::SHA256.new
case protocol_version
when 'ECv1'
verify_and_parse_message_ecv1
when 'ECv2'
verify_and_parse_message_ecv2
else
raise ArgumentError, "unknown protocolVersion #{protocol_version}"
end
end

def verify_and_parse_message_ecv1
signed_bytes = to_length_value(
'Google',
recipient_id,
protocol_version,
signed_message
)
verified = verification_keys['keys'].any? do |key|
next if key['protocolVersion'] != protocol_version

ec = OpenSSL::PKey::EC.new(Base64.strict_decode64(key['keyValue']))
ec.verify(digest, Base64.strict_decode64(signature), signed_bytes)
end
verified = valid_key_signatures?(
verification_keys,
[signature],
signed_bytes
)

raise SignatureInvalidError unless verified
JSON.parse(signed_message)
end

def verify_and_parse_message_ecv2
raise SignatureInvalidError, 'intermediate certificate is expired' if intermediate_key_expired?
raise SignatureInvalidError, 'no valid signature of intermediate key' unless intermediate_key_signature_verified?
raise SignatureInvalidError, 'signature of signedMessage does not match' unless payload_signature_verified?

JSON.parse(signed_message)
end

### ECv2 Methods ###
def intermediate_key_signature_verified?
intermediate_signatures = intermediate_signing_key['signatures']
signed_bytes = [sender_id, protocol_version, intermediate_signing_key['signedKey']].map do |str|
[str.length].pack('V') + str
end.join

# Check at least one of the intermediate keys signed the message
valid_key_signatures?(
verification_keys,
intermediate_signatures,
signed_bytes
)
end

def payload_signature_verified?
signed_string_message = [sender_id, ecv2_recipient_id, protocol_version, signed_message].map do |str|
[str.length].pack('V') + str
end.join

# Check that the intermediate key signed the message
pkey = OpenSSL::PKey::EC.new(Base64.strict_decode64(intermediate_signing_key_signed_key['keyValue']))
valid_key_signatures?(
[pkey],
[signature],
signed_string_message
)
end

def valid_key_signatures?(signing_keys, signatures, signed)
signing_keys.product(signatures).any? do |key, sig|
key.verify(OpenSSL::Digest::SHA256.new, Base64.strict_decode64(sig), signed)
end
end

def verification_keys
@verification_keys ||= begin
root_signing_keys = raw_verification_keys['keys'].select do |key|
key['protocolVersion'] == protocol_version
end

root_signing_keys.map! do |key|
OpenSSL::PKey::EC.new(Base64.strict_decode64(key['keyValue']))
end
end
end

def intermediate_key_expired?
cur_millis = (Time.now.to_f * 1000).floor
intermediate_signing_key_signed_key['keyExpiration'].to_i <= cur_millis
end

def intermediate_signing_key_signed_key
@intermediate_signing_key_signed_key ||= JSON.parse(intermediate_signing_key['signedKey'])
end

def ecv2_recipient_id
"merchant:#{recipient_id}"
end

def sender_id
'Google'
end
end
end
25 changes: 14 additions & 11 deletions lib/r2d2/util.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ def build_token(token_attrs, recipient_id: nil, verification_keys: nil)
case protocol_version
when 'ECv0'
AndroidPayToken.new(token_attrs)
when 'ECv1'
when 'ECv1', 'ECv2'
raise ArgumentError, "missing keyword: recipient_id" if recipient_id.nil?
raise ArgumentError, "missing keyword: verification_keys" if verification_keys.nil?

Expand All @@ -29,12 +29,13 @@ def generate_shared_secret(private_key, ephemeral_public_key)
private_key.dh_compute_key(point)
end

def derive_hkdf_keys(ephemeral_public_key, shared_secret, info)
def derive_hkdf_keys(ephemeral_public_key, shared_secret, info, key_length_bytes = 16)
key_material = Base64.decode64(ephemeral_public_key) + shared_secret
hkdf_bytes = hkdf(key_material, info)
hkdf_bytes = hkdf(key_material, info, key_length_bytes * 2)
## No possibility for out of bounds reads, ruby prevents it when indexing a string past its maximum index.
{
symmetric_encryption_key: hkdf_bytes[0..15],
mac_key: hkdf_bytes[16..32]
symmetric_encryption_key: hkdf_bytes[0..(key_length_bytes - 1)],
mac_key: hkdf_bytes[key_length_bytes..(key_length_bytes * 2)]
}
end

Expand All @@ -44,8 +45,10 @@ def verify_mac(mac_key, encrypted_message, tag)
raise TagVerificationError unless secure_compare(mac, Base64.decode64(tag))
end

def decrypt_message(encrypted_data, symmetric_key)
decipher = OpenSSL::Cipher::AES128.new(:CTR)
def decrypt_message(encrypted_data, symmetric_key, cipher_key_length_bits = 128)
raise ArgumentError, "Invalid cipher_key_length #{cipher_key_length_bits} must be 128 or 256" unless [128, 256].include?(cipher_key_length_bits)

decipher = cipher_key_length_bits == 256 ? OpenSSL::Cipher::AES256.new(:CTR) : OpenSSL::Cipher::AES128.new(:CTR)
decipher.decrypt
decipher.key = symmetric_key
decipher.update(Base64.decode64(encrypted_data)) + decipher.final
Expand Down Expand Up @@ -83,8 +86,8 @@ def secure_compare(a, b)
end

if defined?(OpenSSL::KDF) && OpenSSL::KDF.respond_to?(:hkdf)
def hkdf(key_material, info)
OpenSSL::KDF.hkdf(key_material, salt: 0.chr * 32, info: info, length: 32, hash: 'sha256')
def hkdf(key_material, info, length = 32)
OpenSSL::KDF.hkdf(key_material, salt: 0.chr * 32, info: info, length: length, hash: 'sha256')
end
else
begin
Expand All @@ -96,8 +99,8 @@ def hkdf(key_material, info)
raise
end

def hkdf(key_material, info)
HKDF.new(key_material, algorithm: 'SHA256', info: info).next_bytes(32)
def hkdf(key_material, info, length = 32)
HKDF.new(key_material, algorithm: 'SHA256', info: info).next_bytes(length)
end
end
end
Expand Down
8 changes: 0 additions & 8 deletions test/fixtures/ec_v1/google_verification_key_production.json

This file was deleted.

8 changes: 0 additions & 8 deletions test/fixtures/ec_v1/google_verification_key_test.json

This file was deleted.

1 change: 1 addition & 0 deletions test/fixtures/ec_v2/tokenized_card.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"signature":"MEYCIQCP7uCYgQ7Lf8qOhEGAe/FSxc2QBLxcXJZ0NBLrl1ak0gIhAPg/HoK4bEZFZSonrIn9mTX45VyXPekWVV5nClVUAe+z","intermediateSigningKey":{"signedKey":"{\"keyValue\":\"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEvCTPDitLOv+BQhhQuzBPHbD6sktOsn0SakF0fE9yj328giZXZbec4aZz9js4U3mg8Vjw1+Xxzx+icTpExJ4xqw\\u003d\\u003d\",\"keyExpiration\":\"1595702501149\"}","signatures":["MEUCIAncYUL4uahYYORjyWHNgnt55e0mU7zAJPYW1nG6qb80AiEA1jZD3oo89utjQdTZkwEQo7qbBj/EXK4pntsGJri8cAk\u003d"]},"protocolVersion":"ECv2","signedMessage":"{\"encryptedMessage\":\"FnSPaPKyUvXs6SckETjCcvf/Qh2KLkTLUOZZyjX+NYpFmlQ9AWESUBhyVjK0hPzWAQl8oaakaiVerdzuL7OarTQpiGpDDevNKP/JVhEk0gaVoAwnEM+CAktGOZdSFRhSjLRYgabxOadDbM7qkFeIasipxlWMXEntXteaOtYOxk17ywSKSwiDqa7c3Vx9ayvD6VSXd1wtXfA4VtzMRSSMzMLSxCBSGIs2edxhCADZu9LTOkw6Ms7JTtCD5/tM9LlYYFCYPuxQZjTvARJ56Tstpw+E8iHz8L7yEH1vsIMeiONhM1YvHRuD9s7KtMl7NBKPju1079mv42MbcYX1R0FMyOwQVASJgieZ5Dt6xxS4v6Yg7IsW3f8Gbpd74ezwAhSTRE3vV+N2x+93rX/5Id8nibTinVkNzqIEKyRA27YJ2t6YVZtcxLToydjpR3DFG4WqTDX0l+vlOb6h1NHbPP4ZE0WAaKOeQi7vVdneVhlOeNLNFVyT/1wRUAbl3Ygx0doxPuZZRgDUARO2Gp9PVY6/JOXWP2g8zaY\\u003d\",\"ephemeralPublicKey\":\"BMkR/FU0LxW+27P3m4pdA5DlVSIt2GdLT2YbhIuk+hWG6/j2s5rHGJ5bveTsyESaW21mmznJZpUlzvTVAd0CxZs\\u003d\",\"tag\":\"41mpupWETyyl3KMg3b4cF56CDmOxf1rusXkwDLlTEHY\\u003d\"}"}
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"keys": [
{
"keyValue": "nope",
"protocolVersion": "foo"
},
{
"keyValue": "not today",
"protocolVersion": "bar",
"keyExpiration": "2154841200000"
},
{
"keyValue": "and never again",
"protocolVersion": "baz",
"keyExpiration": "2154841200000"
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"keys": [
{
"keyValue": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAENnoaYTAh15xpR65XRw7jHYj7vNUIGu5I4OmLCrORWwdjrcrED+bJo+nF2HyA5hnH12Dqt1bR8mqKBXynG3HBNw==",
"protocolVersion": "ECv1"
},
{
"keyValue": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAElLiHStI30O9lVplgRhBN1AdlQdWyYgjQAcK3vgrqTvxs9WFkLs7CrxGge79+N5AHlklIHwlKu4WKv8E5IFX8DA==",
"protocolVersion": "ECv2",
"keyExpiration": "2154841200000"
},
{
"keyValue": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAElLiHStI30O9lVplgRhBN1AdlQdWyYgjQAcK3vgrqTvxs9WFkLs7CrxGge79+N5AHlklIHwlKu4WKv8E5IFX8DA==",
"protocolVersion": "ECv2SigningOnly",
"keyExpiration": "2154841200000"
}
]
}
18 changes: 18 additions & 0 deletions test/fixtures/verification_keys/google_verification_key_test.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"keys": [
{
"keyValue": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEIsFro6K+IUxRr4yFTOTO+kFCCEvHo7B9IOMLxah6c977oFzX\/beObH4a9OfosMHmft3JJZ6B3xpjIb8kduK4\/A==",
"protocolVersion": "ECv1"
},
{
"keyValue": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEGnJ7Yo1sX9b4kr4Aa5uq58JRQfzD8bIJXw7WXaap\/hVE+PnFxvjx4nVxt79SdRuUVeu++HZD0cGAv4IOznc96w==",
"protocolVersion": "ECv2",
"keyExpiration": "2154841200000"
},
{
"keyValue": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEGnJ7Yo1sX9b4kr4Aa5uq58JRQfzD8bIJXw7WXaap\/hVE+PnFxvjx4nVxt79SdRuUVeu++HZD0cGAv4IOznc96w==",
"protocolVersion": "ECv2SigningOnly",
"keyExpiration": "2154841200000"
}
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ module R2D2
class GooglePayTokenTest < Minitest::Test
def setup
@recipient_id = 'merchant:12345678901234567890'
@fixtures = __dir__ + "/fixtures/ec_v1/"
@token = JSON.parse(File.read(@fixtures + "tokenized_card.json"))
@private_key = File.read(@fixtures + "private_key.pem")
@verification_keys = JSON.parse(File.read(@fixtures + "google_verification_key_test.json"))
@fixtures = __dir__ + "/fixtures/"
@token = JSON.parse(File.read(@fixtures + "ec_v1/tokenized_card.json"))
@private_key = File.read(@fixtures + "google_pay_token_private_key.pem")
@verification_keys = JSON.parse(File.read(@fixtures + "verification_keys/google_verification_key_test.json"))
Timecop.freeze(Time.at(1509713963))
end

Expand Down Expand Up @@ -36,7 +36,7 @@ def test_decrypted_tokenized_card
end

def test_decrypted_card
@token = JSON.parse(File.read(@fixtures + 'card.json'))
@token = JSON.parse(File.read(@fixtures + 'ec_v1/card.json'))
expected = {
"messageExpiration" => "1510319499834",
"paymentMethod" => "CARD",
Expand All @@ -62,23 +62,23 @@ def test_wrong_signature
end

def test_wrong_verification_key
@verification_keys = JSON.parse(File.read(@fixtures + "google_verification_key_production.json"))
@verification_keys = JSON.parse(File.read(@fixtures + "verification_keys/google_verification_key_production.json"))

assert_raises R2D2::SignatureInvalidError do
new_token.decrypt(@private_key)
end
end

def test_unknown_verification_key_version
@verification_keys['keys'][0]['protocolVersion'] = 'foo'
@verification_keys = JSON.parse(File.read(@fixtures + "verification_keys/bad_google_verification_key_test.json"))

assert_raises R2D2::SignatureInvalidError do
new_token.decrypt(@private_key)
end
end

def test_multiple_verification_keys
production_keys = JSON.parse(File.read(@fixtures + "google_verification_key_production.json"))['keys']
production_keys = JSON.parse(File.read(@fixtures + "verification_keys/google_verification_key_production.json"))['keys']
@verification_keys = { 'keys' => production_keys + @verification_keys['keys'] }

assert new_token.decrypt(@private_key)
Expand Down
Loading

0 comments on commit ac254a5

Please sign in to comment.