diff --git a/lib/r2d2/google_pay_token.rb b/lib/r2d2/google_pay_token.rb index 6e94093..355f5f8 100644 --- a/lib/r2d2/google_pay_token.rb +++ b/lib/r2d2/google_pay_token.rb @@ -2,14 +2,17 @@ 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) @@ -17,14 +20,19 @@ def decrypt(private_key_pem) 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 @@ -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 diff --git a/lib/r2d2/util.rb b/lib/r2d2/util.rb index a14ab29..3954a0d 100644 --- a/lib/r2d2/util.rb +++ b/lib/r2d2/util.rb @@ -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? @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/test/fixtures/ec_v1/google_verification_key_production.json b/test/fixtures/ec_v1/google_verification_key_production.json deleted file mode 100644 index 5e4cf2b..0000000 --- a/test/fixtures/ec_v1/google_verification_key_production.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "keys": [ - { - "keyValue": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAENnoaYTAh15xpR65XRw7jHYj7vNUIGu5I4OmLCrORWwdjrcrED+bJo+nF2HyA5hnH12Dqt1bR8mqKBXynG3HBNw==", - "protocolVersion": "ECv1" - } - ] -} diff --git a/test/fixtures/ec_v1/google_verification_key_test.json b/test/fixtures/ec_v1/google_verification_key_test.json deleted file mode 100644 index ab3c04e..0000000 --- a/test/fixtures/ec_v1/google_verification_key_test.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "keys": [ - { - "keyValue": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEIsFro6K+IUxRr4yFTOTO+kFCCEvHo7B9IOMLxah6c977oFzX\/beObH4a9OfosMHmft3JJZ6B3xpjIb8kduK4\/A==", - "protocolVersion": "ECv1" - } - ] -} diff --git a/test/fixtures/ec_v2/tokenized_card.json b/test/fixtures/ec_v2/tokenized_card.json new file mode 100644 index 0000000..d442ed4 --- /dev/null +++ b/test/fixtures/ec_v2/tokenized_card.json @@ -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\"}"} diff --git a/test/fixtures/ec_v1/private_key.pem b/test/fixtures/google_pay_token_private_key.pem similarity index 100% rename from test/fixtures/ec_v1/private_key.pem rename to test/fixtures/google_pay_token_private_key.pem diff --git a/test/fixtures/verification_keys/bad_google_verification_key_test.json b/test/fixtures/verification_keys/bad_google_verification_key_test.json new file mode 100644 index 0000000..c7b8295 --- /dev/null +++ b/test/fixtures/verification_keys/bad_google_verification_key_test.json @@ -0,0 +1,18 @@ +{ + "keys": [ + { + "keyValue": "nope", + "protocolVersion": "foo" + }, + { + "keyValue": "not today", + "protocolVersion": "bar", + "keyExpiration": "2154841200000" + }, + { + "keyValue": "and never again", + "protocolVersion": "baz", + "keyExpiration": "2154841200000" + } + ] +} \ No newline at end of file diff --git a/test/fixtures/verification_keys/google_verification_key_production.json b/test/fixtures/verification_keys/google_verification_key_production.json new file mode 100644 index 0000000..c651700 --- /dev/null +++ b/test/fixtures/verification_keys/google_verification_key_production.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/test/fixtures/verification_keys/google_verification_key_test.json b/test/fixtures/verification_keys/google_verification_key_test.json new file mode 100644 index 0000000..4e11611 --- /dev/null +++ b/test/fixtures/verification_keys/google_verification_key_test.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/test/google_pay_token_test.rb b/test/google_pay_token_ecv1_test.rb similarity index 78% rename from test/google_pay_token_test.rb rename to test/google_pay_token_ecv1_test.rb index e53c74b..312eb34 100644 --- a/test/google_pay_token_test.rb +++ b/test/google_pay_token_ecv1_test.rb @@ -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 @@ -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", @@ -62,7 +62,7 @@ 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) @@ -70,7 +70,7 @@ def test_wrong_verification_key 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) @@ -78,7 +78,7 @@ def test_unknown_verification_key_version 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) diff --git a/test/google_pay_token_ecv2_test.rb b/test/google_pay_token_ecv2_test.rb new file mode 100644 index 0000000..3c4311d --- /dev/null +++ b/test/google_pay_token_ecv2_test.rb @@ -0,0 +1,98 @@ +require "test_helper" + +module R2D2 + class GooglePayTokenTestV2 < Minitest::Test + + def setup + @recipient_id = 'merchant:12345678901234567890' + @fixtures = __dir__ + "/fixtures/" + @token = JSON.parse(File.read(@fixtures + "ec_v2/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(1595000067)) + end + + def teardown + Timecop.return + end + + def test_decrypted_tokenized_card + expected = { + "messageExpiration" => "1595616845229", + "messageId" => "AH2EjtffDAl2Jrk2579gUyuaF3ensj4NlaHbyEvOQWtj9IrQNdqJYHl7Vun2kGyFZbuebyKwpDP8fxtSJi-vPIDYbtGwM13_-8o7dBHg74WbXMIwSvW__pCnwmpWn2tFK4PbZ77wRKMh", + "paymentMethod" => "CARD", + "paymentMethodDetails" => { + "expirationYear" => 2025, + "expirationMonth" => 12, + "pan" => "4895370012003478", + "authMethod" => "CRYPTOGRAM_3DS", + "eciIndicator" => "07", + "cryptogram" => "AgAAAAAABk4DWZ4C28yUQAAAAAA=" + } + } + decrypted = new_token.decrypt(@private_key) + + assert_equal expected, decrypted + end + + def test_wrong_signature + @token['signature'] = "MEQCIDxBoUCoFRGReLdZ/cABlSSRIKoOEFoU3e27c14vMZtfAiBtX3pGMEpnw6mSAbnagCCgHlCk3NcFwWYEyxIE6KGZVA\u003d\u003d" + + assert_raises R2D2::SignatureInvalidError do + new_token.decrypt(@private_key) + end + end + + def test_invalid_intermediate_signing_key + @token['intermediateSigningKey']['signatures'] = ["MEQCIDxBoUCoFRGReLdZ/cABlSSRIKoOEFoU3e27c14vMZtfAiBtX3pGMEpnw6mSAbnagCCgHlCk3NcFwWYEyxIE6KGZVA\u003d\u003d"] + + assert_raises R2D2::SignatureInvalidError do + new_token.decrypt(@private_key) + end + end + + def test_wrong_verification_key + @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 = 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_expired_message + ## decryptped_token["messageExpiration"]=>"1595616845229" + Timecop.freeze(Time.at(1595616846)) do + assert_raises R2D2::MessageExpiredError do + new_token.decrypt(@private_key) + end + end + end + + def test_intermediate_key_expired + ### token["intermediateSigningKey"]["signedKey"]["keyExpiration"] => "1595702501149" + Timecop.freeze(Time.at(1595702502)) do + assert_raises R2D2::SignatureInvalidError do + new_token.decrypt(@private_key) + end + end + end + + private + + def new_token + R2D2::GooglePayToken.new( + @token, + recipient_id: @recipient_id, + verification_keys: @verification_keys + ) + end + end +end diff --git a/test/token_builder_test.rb b/test/token_builder_test.rb index e391d36..b14e89f 100644 --- a/test/token_builder_test.rb +++ b/test/token_builder_test.rb @@ -5,7 +5,7 @@ class TokenBuilderTest < Minitest::Test def setup @fixtures = __dir__ + "/fixtures/" @recipient_id = 'merchant:12345678901234567890' - @verification_keys = JSON.parse(File.read(@fixtures + "ec_v1/google_verification_key_test.json")) + @verification_keys = JSON.parse(File.read(@fixtures + "verification_keys/google_verification_key_test.json")) end def test_builds_android_pay_token @@ -18,6 +18,11 @@ def test_builds_google_pay_token assert_instance_of GooglePayToken, R2D2.build_token(token_attrs, recipient_id: @recipient_id, verification_keys: @verification_keys) end + def test_builds_google_pay_token_v2 + token_attrs = JSON.parse(File.read(@fixtures + "ec_v2/tokenized_card.json")) + assert_instance_of GooglePayToken, R2D2.build_token(token_attrs, recipient_id: @recipient_id, verification_keys: @verification_keys) + end + def test_building_token_raises_with_unknown_protocol_version token_attrs = JSON.parse(File.read(@fixtures + "ec_v1/tokenized_card.json")) token_attrs['protocolVersion'] = 'foo'