From 42b3e18536462583bbb02080fa9163f7dfacceab Mon Sep 17 00:00:00 2001 From: Bror Bisgaard Winther Date: Fri, 12 Jan 2024 02:49:59 +0100 Subject: [PATCH] Intermidiate commit, squash/reword later --- lib/aliquot/payment.rb | 7 +- lib/aliquot/validator.rb | 86 ++-- spec/integration_spec.rb | 4 +- spec/lib/aliquot/payment_spec.rb | 113 +++-- spec/lib/aliquot/validator_spec.rb | 641 +++++++++++++++++------------ 5 files changed, 509 insertions(+), 342 deletions(-) diff --git a/lib/aliquot/payment.rb b/lib/aliquot/payment.rb index a0798e1..df9444f 100644 --- a/lib/aliquot/payment.rb +++ b/lib/aliquot/payment.rb @@ -23,7 +23,8 @@ def initialize(token_string, shared_secret, recipient_id, signing_keys: ENV['GOOGLE_SIGNING_KEYS']) begin - validation = Aliquot::Validator::Token.new(JSON.parse(token_string)) + token = JSON.parse(token_string) + validation = Aliquot::Validator::Token.new(token) validation.validate rescue JSON::JSONError => e raise InputError, "token JSON is invalid, #{e.message}" @@ -66,6 +67,8 @@ def process begin @message = JSON.parse(decrypt(aes_key, @signed_message[:encryptedMessage])) + @message.merge!('threedsCryptogram' => @message.delete('3dsCryptogram')) if @message['3dsCryptogram'] + @message rescue JSON::JSONError => e raise InputError, "encryptedMessage JSON is invalid, #{e.message}" rescue => e @@ -142,6 +145,7 @@ def check_signature # Check that the intermediate key signed the message pkey = OpenSSL::PKey::EC.new(Base64.strict_decode64(@intermediate_key[:keyValue])) + raise InvalidSignatureError, 'signature of signedMessage does not match' unless pkey.verify(new_digest, message_signature, signed_string_message) intermediate_signatures = @token[:intermediateSigningKey][:signatures] @@ -173,6 +177,7 @@ def root_keys def valid_intermediate_key_signatures?(signing_keys, signatures, signed) signing_keys.product(signatures).each do |key, sig| + return true if key.verify(new_digest, Base64.strict_decode64(sig), signed) end false diff --git a/lib/aliquot/validator.rb b/lib/aliquot/validator.rb index 9c99048..3fe5d02 100644 --- a/lib/aliquot/validator.rb +++ b/lib/aliquot/validator.rb @@ -21,8 +21,8 @@ module Validator base64_asn1?: 'must be base64-encoded ANS.1 value', json?: 'must be valid JSON', - is_authMethodCryptogram3DS: 'authMethod CRYPTOGRAM_3DS requires eciIndicator', - is_authMethodCard: 'eciIndicator/cryptogram must be omitted when PAN_ONLY', + is_authMethodCryptogram3DS: 'authMethod CRYPTOGRAM_3DS or 3DS requires eciIndicator', + is_authMethodCard: 'eciIndicator/cryptogram/3dsCryptogram must be omitted when PAN_ONLY', }.freeze def self.base64_check(value) @@ -168,39 +168,57 @@ class SignedMessageContract < Dry::Validation::Contract SignedMessageSchema = SignedMessageContract.new - # DRY-Validation schema for paymentMethodDetails component Google Pay token - class PaymentMethodDetailsContract < Dry::Validation::Contract + class CommonPaymentMethodDetailsContract < Dry::Validation::Contract json do - required(:pan).filled(:str?) required(:expirationMonth).filled(:int?) required(:expirationYear).filled(:int?) - required(:authMethod).filled(:str?, included_in?: %w[PAN_ONLY CRYPTOGRAM_3DS]) - - optional(:cryptogram).filled(:str?) - optional(:eciIndicator).filled(:str?) end - rule(:pan).validate(:integer_string?, :pan?) rule(:expirationMonth).validate(:month?) rule(:expirationYear).validate(:year?) - rule(:eciIndicator).validate(:eci?) + end + + class ECv1_PaymentMethodDetailsContract < Dry::Validation::Contract + json(CommonPaymentMethodDetailsContract.schema) do + required(:pan).filled(:str?) + end + + rule(:pan).validate(:integer_string?, :pan?) + end - rule(:cryptogram) do - key.failure('is missing') if 'CRYPTOGRAM_3DS'.eql?(values[:authMethod]) && - values[:cryptogram].nil? + class ECv1_TokenizedPaymentMethodDetailsContract < Dry::Validation::Contract + json(CommonPaymentMethodDetailsContract.schema) do + required(:dpan).filled(:str?) + required(:threedsCryptogram).filled(:str?) + required(:eciIndicator).filled(:str?) + required(:authMethod).filled(:str?, included_in?: %w[3DS]) end - rule(:cryptogram) do - key.failure('cannot be defined') if 'PAN_ONLY'.eql?(values[:authMethod]) && - !values[:cryptogram].nil? + rule(:dpan).validate(:integer_string?, :pan?) + rule(:eciIndicator).validate(:eci?) + end + + class ECv2_PaymentMethodDetailsContract < Dry::Validation::Contract + json(CommonPaymentMethodDetailsContract.schema) do + required(:pan).filled(:str?) + required(:authMethod).filled(:str?, included_in?: %w[PAN_ONLY]) end - rule(:eciIndicator) do - key.failure('cannot be defined') if 'PAN_ONLY'.eql?(values[:authMethod]) && - !values[:eciIndicator].nil? + rule(:pan).validate(:integer_string?, :pan?) + end + + class ECv2_TokenizedPaymentMethodDetailsContract < Dry::Validation::Contract + json(CommonPaymentMethodDetailsContract.schema) do + required(:pan).filled(:str?) + required(:cryptogram).filled(:str?) + required(:eciIndicator).filled(:str?) + required(:authMethod).filled(:str?, included_in?: %w[CRYPTOGRAM_3DS]) end + + rule(:pan).validate(:integer_string?, :pan?) + rule(:eciIndicator).validate(:eci?) end - PaymentMethodDetailsSchema = PaymentMethodDetailsContract.new + # PaymentMethodDetailsSchema = PaymentMethodDetailsContract.new # DRY-Validation schema for encryptedMessage component Google Pay token class EncryptedMessageContract < Dry::Validation::Contract @@ -208,12 +226,34 @@ class EncryptedMessageContract < Dry::Validation::Contract required(:messageExpiration).filled(:str?) required(:messageId).filled(:str?) required(:paymentMethod).filled(:str?) - required(:paymentMethodDetails).filled(:hash).schema(PaymentMethodDetailsContract.schema) + required(:paymentMethodDetails).filled(:hash) optional(:gatewayMerchantId).filled(:str?) end rule(:messageExpiration).validate(:integer_string?) + rule(:paymentMethodDetails) do + contract = nil + if 'ECv1'.eql?(values[:protocolVersion]) + if 'TOKENIZED_CARD'.eql?(values[:paymentMethod]) + contract = ECv1_TokenizedPaymentMethodDetailsContract.new + else + contract = ECv1_PaymentMethodDetailsContract.new + end + else + if 'CRYPTOGRAM_3DS'.eql?(values[:authMethod]) + contract = ECv2_TokenizedPaymentMethodDetailsContract.new + else + contract = ECv2_PaymentMethodDetailsContract.new + end + key.failure("test") unless + contract.(values) + end + end rule(:paymentMethod) do - key.failure('must be equal to CARD') unless 'CARD'.eql?(value) + if '3DS'.eql?(values[:paymentMethodDetails] && values[:paymentMethodDetails]['authMethod']) # Tokenized ECv1 + key.failure('must be equal to TOKENIZED_CARD') unless 'TOKENIZED_CARD'.eql?(value) + else + key.failure('must be equal to CARD') unless 'CARD'.eql?(value) + end end end diff --git a/spec/integration_spec.rb b/spec/integration_spec.rb index e952685..996db26 100644 --- a/spec/integration_spec.rb +++ b/spec/integration_spec.rb @@ -47,13 +47,13 @@ end context :ECv1 do - let (:generator) { AliquotPay.new(:ECv1) } + let (:generator) { AliquotPay.new(protocol_version: :ECv1, type: :browser) } include_examples 'common integration tests' end context :ECv2 do - let (:generator) { AliquotPay.new(:ECv2) } + let (:generator) { AliquotPay.new(protocol_version: :ECv2, type: :browser) } include_examples 'common integration tests' diff --git a/spec/lib/aliquot/payment_spec.rb b/spec/lib/aliquot/payment_spec.rb index 9524930..af37079 100644 --- a/spec/lib/aliquot/payment_spec.rb +++ b/spec/lib/aliquot/payment_spec.rb @@ -7,19 +7,7 @@ # Test that all `raise` errors are caught and handled gracefully. # Ordered by occurrence of raise in the `payment.rb` source code. -shared_examples Aliquot::Payment do - - it 'decrypts' do - expect { subject.call }.to_not raise_error - expect(subject.call[:paymentMethodDetails]).to include(authMethod: 'PAN_ONLY') - end - - it 'decrypts with CRYPTOGRAM_3DS' do - generator.auth_method = 'CRYPTOGRAM_3DS' - expect { subject.call }.to_not raise_error - expect(subject.call[:paymentMethodDetails]).to include(authMethod: 'CRYPTOGRAM_3DS') - end - +shared_examples 'all protocol versions' do # CB: Not sure how to trigger this test as JSON.parse has changed since 2.3 # see https://clearhaus.slack.com/archives/C3LG75WE9/p1661940442665459 it 'rejects invalid token JSON gracefully' @@ -57,9 +45,9 @@ it 'rejects non-base64 shared_secret' do block = proc do Aliquot::Payment.new(generator.token.to_json, - 'not base64', - generator.recipient_id, - signing_keys: generator.extract_root_signing_keys) + 'not base64', + generator.recipient_id, + signing_keys: generator.extract_root_signing_keys) .process end expect(&block).to raise_error(Aliquot::InvalidSharedSecretError, 'shared_secret must be base64') @@ -85,47 +73,86 @@ end end +shared_examples 'only ECv2' do + it 'rejects expired intermediateSigningKey' do + generator.key_expiration = "#{Time.now.to_i - 1}000" + expect { subject.call }.to raise_error(Aliquot::InvalidSignatureError, 'intermediate certificate is expired') + end + + it 'rejects when no signature of intermediateKey is found' do + generator.signatures = AliquotPay.new.build_signatures + expect { subject.call }.to raise_error(Aliquot::InvalidSignatureError, 'no valid signature of intermediate key') + end + + it 'allows invalid intermediate signatures to be present' do + fake_signature = AliquotPay.new.build_signatures.first + real_signature = generator.build_signatures.first + + generator.signatures = [fake_signature, real_signature] + + expect(token['intermediateSigningKey']['signatures']).to include(fake_signature) + expect(token['intermediateSigningKey']['signatures']).to include(real_signature) + + expect { subject.call }.to_not raise_error + end +end + describe Aliquot::Payment do + let(:generator) { AliquotPay.new(protocol_version: :ECv1, type: :browser) } subject do -> do Aliquot::Payment.new(generator.token.to_json, generator.shared_secret, generator.recipient_id, signing_keys: generator.extract_root_signing_keys) - .process + .process end end - let(:token) { generator.token } - context 'ECv1' do - let(:generator) { AliquotPay.new(:ECv1) } - include_examples Aliquot::Payment + context 'non-tokenized' do + let(:generator) { AliquotPay.new(protocol_version: :ECv1, type: :browser) } + let(:token) { generator.token } + + include_examples 'all protocol versions' + it 'decrypts with PAN_ONLY' do + expect { subject.call }.to_not raise_error + expect(subject.call[:paymentMethodDetails]).to_not include('authMethod' => 'PAN_ONLY') + end + end + context 'tokenized' do + let(:generator) { AliquotPay.new(protocol_version: :ECv1, type: :app) } + let(:token) { generator.token } + + include_examples 'all protocol versions' + it 'decrypts with 3DS' do + expect { subject.call }.to_not raise_error + expect(subject.call[:paymentMethodDetails]).to include('authMethod' => '3DS') + end + end end context 'ECv2' do - let(:generator) { AliquotPay.new(:ECv2) } - include_examples Aliquot::Payment - - it 'rejects expired intermediateSigningKey' do - generator.key_expiration = "#{Time.now.to_i - 1}000" - expect { subject.call }.to raise_error(Aliquot::InvalidSignatureError, 'intermediate certificate is expired') - end - - it 'rejects when no signature of intermediateKey is found' do - generator.signatures = AliquotPay.new.build_signatures - expect { subject.call }.to raise_error(Aliquot::InvalidSignatureError, 'no valid signature of intermediate key') + context 'non-tokenized' do + let(:generator) { AliquotPay.new(protocol_version: :ECv2, type: :browser) } + let(:token) { generator.token } + + include_examples 'all protocol versions' + include_examples 'only ECv2' + it 'decrypts' do + expect { subject.call }.to_not raise_error + expect(subject.call[:paymentMethodDetails]).to include('authMethod' => 'PAN_ONLY') + end end - - it 'allows invalid intermediate signatures to be present' do - fake_signature = AliquotPay.new.build_signatures.first - real_signature = generator.build_signatures.first - - generator.signatures = [fake_signature, real_signature] - - expect(token['intermediateSigningKey']['signatures']).to include(fake_signature) - expect(token['intermediateSigningKey']['signatures']).to include(real_signature) - - expect { subject.call }.to_not raise_error + context 'tokenized' do + let(:generator) { AliquotPay.new(protocol_version: :ECv2, type: :app) } + let(:token) { generator.token } + + include_examples 'all protocol versions' + include_examples 'only ECv2' + it 'decrypts' do + expect { subject.call }.to_not raise_error + expect(subject.call[:paymentMethodDetails]).to include('authMethod' => 'CRYPTOGRAM_3DS') + end end end end diff --git a/spec/lib/aliquot/validator_spec.rb b/spec/lib/aliquot/validator_spec.rb index 7406218..1406ac4 100644 --- a/spec/lib/aliquot/validator_spec.rb +++ b/spec/lib/aliquot/validator_spec.rb @@ -5,13 +5,12 @@ require 'expectations/schema' require 'json' - # Tests to make sure we enforce what we want in the validator. + shared_examples 'Validator Spec' do context 'TokenSchema' do let(:schema) { Aliquot::Validator::TokenSchema } - let(:input) { token } context 'signature' do it 'must exist' do @@ -78,426 +77,522 @@ is_expected.to dissatisfy_schema(schema, {signedMessage: ['must be valid JSON']}) end end + + context 'SignedMessageScheme' do + let(:schema) { Aliquot::Validator::SignedMessageSchema } + let(:token) { generator.build_signed_message } + + context 'encryptedMessage' do + it 'must exist' do + token.delete('encryptedMessage') + is_expected.to dissatisfy_schema(schema, {encryptedMessage: ['is missing']}) + end + + it 'must be filled' do + token['encryptedMessage'] = '' + is_expected.to dissatisfy_schema(schema, {encryptedMessage: ['must be filled']}) + end + + it 'must be a string' do + generator.encrypted_message = 123 + is_expected.to dissatisfy_schema(schema, {encryptedMessage: ['must be a string']}) + end + + it 'must be base64' do + generator.encrypted_message = 'not base64' + is_expected.to dissatisfy_schema(schema, {encryptedMessage: ['must be Base64']}) + end + end + + context 'ephemeralPublicKey' do + it 'must exist' do + token.delete('ephemeralPublicKey') + is_expected.to dissatisfy_schema(schema, {ephemeralPublicKey: ['is missing']}) + end + + it 'must be filled' do + token['ephemeralPublicKey'] = '' + is_expected.to dissatisfy_schema(schema, {ephemeralPublicKey: ['must be filled']}) + end + + it 'must be a string' do + generator.ephemeral_public_key = 123 + is_expected.to dissatisfy_schema(schema, {ephemeralPublicKey: ['must be a string']}) + end + + it 'must be base64' do + generator.ephemeral_public_key = 'not base64' + is_expected.to dissatisfy_schema(schema, {ephemeralPublicKey: ['must be Base64']}) + end + end + end end - context 'SignedMessageScheme' do - let(:schema) { Aliquot::Validator::SignedMessageSchema } - let(:input) { generator.build_signed_message } + context 'EncryptedMessageSchema' do + let(:schema) { Aliquot::Validator::EncryptedMessageSchema } + let(:token) { generator.build_cleartext_message } - context 'encryptedMessage' do + context 'messageExpiration' do it 'must exist' do - input.delete('encryptedMessage') - is_expected.to dissatisfy_schema(schema, {encryptedMessage: ['is missing']}) + token.delete('messageExpiration') + is_expected.to dissatisfy_schema(schema, {messageExpiration: ['is missing']}) end it 'must be filled' do - input['encryptedMessage'] = '' - is_expected.to dissatisfy_schema(schema, {encryptedMessage: ['must be filled']}) + generator.message_expiration = '' + is_expected.to dissatisfy_schema(schema, {messageExpiration: ['must be filled']}) end it 'must be a string' do - generator.encrypted_message = 123 - is_expected.to dissatisfy_schema(schema, {encryptedMessage: ['must be a string']}) + generator.message_expiration = 123 + is_expected.to dissatisfy_schema(schema, {messageExpiration: ['must be a string']}) end - it 'must be base64' do - generator.encrypted_message = 'not base64' - is_expected.to dissatisfy_schema(schema, {encryptedMessage: ['must be Base64']}) + it 'must be an integer string' do + generator.message_expiration = 'not integer string' + is_expected.to dissatisfy_schema(schema, {messageExpiration: ['must be string encoded integer']}) end end - context 'ephemeralPublicKey' do + context 'messageId' do it 'must exist' do - input.delete('ephemeralPublicKey') - is_expected.to dissatisfy_schema(schema, {ephemeralPublicKey: ['is missing']}) + token.delete('messageId') + is_expected.to dissatisfy_schema(schema, {messageId: ['is missing']}) end it 'must be filled' do - input['ephemeralPublicKey'] = '' - is_expected.to dissatisfy_schema(schema, {ephemeralPublicKey: ['must be filled']}) + generator.message_id = '' + is_expected.to dissatisfy_schema(schema, {messageId: ['must be filled']}) end it 'must be a string' do - generator.ephemeral_public_key = 123 - is_expected.to dissatisfy_schema(schema, {ephemeralPublicKey: ['must be a string']}) + generator.message_id = 123 + is_expected.to dissatisfy_schema(schema, {messageId: ['must be a string']}) end + end - it 'must be base64' do - generator.ephemeral_public_key = 'not base64' - is_expected.to dissatisfy_schema(schema, {ephemeralPublicKey: ['must be Base64']}) + context 'paymentMethod' do + it 'must exist' do + token.delete('paymentMethod') + is_expected.to dissatisfy_schema(schema, {paymentMethod: ['is missing']}) + end + + it 'must be filled' do + generator.payment_method = '' + is_expected.to dissatisfy_schema(schema, {paymentMethod: ['must be filled']}) + end + + it 'must be a string' do + generator.payment_method = 123 + is_expected.to dissatisfy_schema(schema, {paymentMethod: ['must be a string']}) + end + + end + + context 'paymentMethodDetails' do + it 'must exist' do + token.delete('paymentMethodDetails') + is_expected.to dissatisfy_schema(schema, {paymentMethodDetails: ['is missing']}) + end + + it 'must be a JSON object' do + generator.payment_method_details = 'not a json object' + is_expected.to dissatisfy_schema(schema, {paymentMethodDetails: ['must be a hash']}) end end end - context 'PaymentMethodDetailsSchema' do - let(:schema) { Aliquot::Validator::PaymentMethodDetailsSchema } - let(:input) { generator.build_payment_method_details } + context 'SignedKeySchema' do + let(:schema) { Aliquot::Validator::SignedKeySchema } + let(:token) { generator.build_signed_key } - context 'pan' do + context 'keyExpiration' do it 'must exist' do - input.delete('pan') - is_expected.to dissatisfy_schema(schema, {pan: ['is missing']}) + token.delete('keyExpiration') + is_expected.to dissatisfy_schema(schema, {keyExpiration: ['is missing']}) end it 'must be filled' do - generator.pan = '' - is_expected.to dissatisfy_schema(schema, {pan: ['must be filled']}) + token['keyExpiration'] = '' + is_expected.to dissatisfy_schema(schema, {keyExpiration: ['must be filled']}) end it 'must be integer string' do - generator.pan = 'no integers here' - is_expected.to dissatisfy_schema(schema, {pan: ['must be string encoded integer', 'must be a PAN']}) - end - - it 'must be a pan' do - generator.pan = '1121412908091872401284' - is_expected.to dissatisfy_schema(schema, {pan: ['must be a PAN']}) + generator.key_expiration = 'not digits' + is_expected.to dissatisfy_schema(schema, {keyExpiration: ['must be string encoded integer']}) end end - context 'expirationMonth' do + context 'keyValue' do it 'must exist' do - input.delete('expirationMonth') - is_expected.to dissatisfy_schema(schema, {expirationMonth: ['is missing']}) + token.delete('keyValue') + is_expected.to dissatisfy_schema(schema, {keyValue: ['is missing']}) end it 'must be filled' do - generator.expiration_month = '' - is_expected.to dissatisfy_schema(schema, {expirationMonth: ['must be filled']}) + token['keyValue'] = '' + is_expected.to dissatisfy_schema(schema, {keyValue: ['must be filled']}) end - it 'must be an integer' do - generator.expiration_month = 'a string' - is_expected.to dissatisfy_schema(schema, {expirationMonth: ['must be an integer']}) + it 'must be ec_public_key' do + generator.key_value = 'not EC public key' + is_expected.to dissatisfy_schema(schema, {keyValue: ['must be an EC public key']}) end + end + end +end - it 'must be a month' do - generator.expiration_month = 13 - is_expected.to dissatisfy_schema(schema, {expirationMonth: ['must be a month (1..12)']}) - end +shared_examples 'ECv2 PaymentMethodDetails' do + + context 'IntermediateSigningKeySchema' do + let(:schema) { Aliquot::Validator::IntermediateSigningKeySchema } + let(:key) { token['intermediateSigningKey'] } + subject do + key end - context 'expirationYear' do + context 'signedKey' do it 'must exist' do - input.delete('expirationYear') - is_expected.to dissatisfy_schema(schema, {expirationYear: ['is missing']}) + token['intermediateSigningKey'].delete('signedKey') + is_expected.to dissatisfy_schema(schema, signedKey: ['is missing']) end it 'must be filled' do - generator.expiration_year = '' - is_expected.to dissatisfy_schema(schema, {expirationYear: ['must be filled']}) + token['intermediateSigningKey']['signedKey'] = '' + is_expected.to dissatisfy_schema(schema, signedKey: ['must be filled']) end - it 'must be an integer' do - generator.expiration_year = 'a string' - is_expected.to dissatisfy_schema(schema, {expirationYear: ['must be an integer']}) + it 'must be a string' do + generator.signed_key_string = 122 + is_expected.to dissatisfy_schema(schema, signedKey: ['must be a string']) end - it 'must be a year' do - generator.expiration_year = 19993 - is_expected.to dissatisfy_schema(schema, {expirationYear: ['must be a year (2000..3000)']}) + # CB: Not sure how to trigger this test as JSON.parse has changed since 2.3 + # see https://clearhaus.slack.com/archives/C3LG75WE9/p1661940442665459 + it 'must be valid json' + + it 'must pass' do + expect(JSON.parse(token.to_json, symbolize_names: false)).to satisfy_schema(schema) end end - context 'authMethod' do + context 'signatures' do it 'must exist' do - input.delete('authMethod') - is_expected.to dissatisfy_schema(schema, {authMethod: ['is missing']}) + token['intermediateSigningKey'].delete('signatures') + is_expected.to dissatisfy_schema(schema, signatures: ['is missing']) end it 'must be filled' do - generator.auth_method = '' - is_expected.to dissatisfy_schema(schema, {authMethod: ['must be filled']}) + generator.signatures = '' + is_expected.to dissatisfy_schema(schema, signatures: ['must be an array']) end - it 'must be a string' do - generator.auth_method = 123 - is_expected.to dissatisfy_schema(schema, {authMethod: ['must be a string']}) + it 'must be an array' do + generator.signatures = 'Not an array' + is_expected.to dissatisfy_schema(schema, signatures: ['must be an array']) end - it 'must be PAN_ONLY or CRYPTOGRAM_3DS' do - generator.auth_method = 'INVALID_AUTH_METHOd' - is_expected.to dissatisfy_schema(schema, {authMethod: ['must be one of: PAN_ONLY, CRYPTOGRAM_3DS']}) + it 'entries must be base64' do + generator.signatures = ['Not base64'] + is_expected.to dissatisfy_schema(schema, signatures: {0 => ['must be Base64']}) end - end - context 'cryptogram' do - context 'when PAN_ONLY' do - it 'must not exist' do - input['cryptogram'] = '05' - is_expected.to dissatisfy_schema(schema, {cryptogram: ['cannot be defined']}) - end + it 'entries must be asn1' do + generator.signatures = ['Not base64'] + is_expected.to dissatisfy_schema(schema, signatures: {0 => ['must be Base64']}) end - context 'when CRYPTOGRAM_3DS' do - before(:each) { generator.auth_method = 'CRYPTOGRAM_3DS' } + it 'must pass' do + expect(JSON.parse(token.to_json, symbolize_names: false)).to satisfy_schema(schema) + end + end - it 'must exist' do - input.delete('cryptogram') - is_expected.to dissatisfy_schema(schema, {cryptogram: ['is missing']}) - end + context 'intermediateSigningKey' do + let(:schema) { Aliquot::Validator::TokenSchema } + subject do + token + end - it 'must be filled' do - generator.cryptogram = '' - is_expected.to dissatisfy_schema(schema, {cryptogram: ['must be filled']}) - end + it 'must exist' do + token.delete('intermediateSigningKey') + is_expected.to dissatisfy_schema(schema, {intermediateSigningKey: ['is missing']}) + end - it 'must be a string' do - generator.cryptogram = 123 - is_expected.to dissatisfy_schema(schema, {cryptogram: ['must be a string']}) - end + it 'must be a JSON object' do + generator.intermediate_signing_key = 'Not a JSON object' + is_expected.to dissatisfy_schema(schema, {intermediateSigningKey: ['must be a hash']}) end end + end +end - context 'eciIndicator' do - context 'when PAN_ONLY' do - it 'must not exist' do - input['eciIndicator'] = '05' - is_expected.to dissatisfy_schema(schema, {eciIndicator: ['cannot be defined']}) - end - end +shared_examples 'CommonPaymentMethodDetailsContract' do + context 'PaymentMethodDetailsSchema' do + let(:token) { generator.build_payment_method_details } - context 'when CRYPTOGRAM_3DS' do - before(:each) { generator.auth_method = 'CRYPTOGRAM_3DS' } + it 'must be filled' do + generator.pan = '' + is_expected.to dissatisfy_schema(schema, {pan: ['must be filled']}) + end - it 'is not required' do - input.delete('eciIndicator') - expect(JSON.parse(input.to_json, symbolize_names: false)).to satisfy_schema(schema) - end + it 'must be integer string' do + generator.pan = 'no integers here' + is_expected.to dissatisfy_schema(schema, {pan: ['must be string encoded integer', 'must be a PAN']}) + end - it 'must be a string' do - generator.eci_indicator = 123 - is_expected.to dissatisfy_schema(schema, {eciIndicator: ['must be a string']}) - end + it 'must be a pan' do + generator.pan = '1121412908091872401284' + is_expected.to dissatisfy_schema(schema, {pan: ['must be a PAN']}) + end + end - it 'must be an ECI' do - generator.eci_indicator = 'ff' - is_expected.to dissatisfy_schema(schema, {eciIndicator: ['must be an ECI']}) - end - end + context 'expirationMonth' do + it 'must exist' do + token.delete('expirationMonth') + is_expected.to dissatisfy_schema(schema, {expirationMonth: ['is missing']}) + end + + it 'must be filled' do + generator.expiration_month = '' + is_expected.to dissatisfy_schema(schema, {expirationMonth: ['must be filled']}) + end + + it 'must be an integer' do + generator.expiration_month = 'a string' + is_expected.to dissatisfy_schema(schema, {expirationMonth: ['must be an integer']}) + end + + it 'must be a month' do + generator.expiration_month = 13 + is_expected.to dissatisfy_schema(schema, {expirationMonth: ['must be a month (1..12)']}) end end - context 'EncryptedMessageSchema' do - let(:schema) { Aliquot::Validator::EncryptedMessageSchema } - let(:input) { generator.build_cleartext_message } + context 'expirationYear' do + it 'must exist' do + token.delete('expirationYear') + is_expected.to dissatisfy_schema(schema, {expirationYear: ['is missing']}) + end - context 'messageExpiration' do - it 'must exist' do - input.delete('messageExpiration') - is_expected.to dissatisfy_schema(schema, {messageExpiration: ['is missing']}) - end + it 'must be filled' do + generator.expiration_year = '' + is_expected.to dissatisfy_schema(schema, {expirationYear: ['must be filled']}) + end - it 'must be filled' do - generator.message_expiration = '' - is_expected.to dissatisfy_schema(schema, {messageExpiration: ['must be filled']}) - end + it 'must be an integer' do + generator.expiration_year = 'a string' + is_expected.to dissatisfy_schema(schema, {expirationYear: ['must be an integer']}) + end - it 'must be a string' do - generator.message_expiration = 123 - is_expected.to dissatisfy_schema(schema, {messageExpiration: ['must be a string']}) - end + it 'must be a year' do + generator.expiration_year = 19993 + is_expected.to dissatisfy_schema(schema, {expirationYear: ['must be a year (2000..3000)']}) + end + end - it 'must be an integer string' do - generator.message_expiration = 'not integer string' - is_expected.to dissatisfy_schema(schema, {messageExpiration: ['must be string encoded integer']}) - end + context 'authMethod' do + it 'must exist' do + token.delete('authMethod') + is_expected.to dissatisfy_schema(schema, {authMethod: ['is missing']}) end - context 'messageId' do - it 'must exist' do - input.delete('messageId') - is_expected.to dissatisfy_schema(schema, {messageId: ['is missing']}) - end + it 'must be filled' do + generator.auth_method = '' + is_expected.to dissatisfy_schema(schema, {authMethod: ['must be filled']}) + end - it 'must be filled' do - generator.message_id = '' - is_expected.to dissatisfy_schema(schema, {messageId: ['must be filled']}) - end + it 'must be a string' do + generator.auth_method = 123 + is_expected.to dissatisfy_schema(schema, {authMethod: ['must be a string']}) + end - it 'must be a string' do - generator.message_id = 123 - is_expected.to dissatisfy_schema(schema, {messageId: ['must be a string']}) + it 'must be PAN_ONLY, CRYPTOGRAM_3DS' do + generator.auth_method = 'INVALID_AUTH_METHOD' + is_expected.to dissatisfy_schema(schema, {authMethod: ['must be one of: PAN_ONLY, CRYPTOGRAM_3DS, 3DS']}) + end + end + + context 'cryptogram' do + context 'when PAN_ONLY' do + it 'must not exist' do + token['cryptogram'] = '05' + is_expected.to dissatisfy_schema(schema, {cryptogram: ['cannot be defined']}) end end - context 'paymentMethod' do + context 'when CRYPTOGRAM_3DS' do + before(:each) { generator.auth_method = 'CRYPTOGRAM_3DS' } + it 'must exist' do - input.delete('paymentMethod') - is_expected.to dissatisfy_schema(schema, {paymentMethod: ['is missing']}) + token.delete('cryptogram') + is_expected.to dissatisfy_schema(schema, {cryptogram: ['is missing']}) end it 'must be filled' do - generator.payment_method = '' - is_expected.to dissatisfy_schema(schema, {paymentMethod: ['must be filled']}) + generator.cryptogram = '' + is_expected.to dissatisfy_schema(schema, {cryptogram: ['must be filled']}) end it 'must be a string' do - generator.payment_method = 123 - is_expected.to dissatisfy_schema(schema, {paymentMethod: ['must be a string']}) - end - - it 'must be CARD' do - generator.payment_method = 'RANDOM' - is_expected.to dissatisfy_schema(schema, {paymentMethod: ['must be equal to CARD']}) + generator.cryptogram = 123 + is_expected.to dissatisfy_schema(schema, {cryptogram: ['must be a string']}) end end + end - context 'paymentMethodDetails' do - it 'must exist' do - input.delete('paymentMethodDetails') - is_expected.to dissatisfy_schema(schema, {paymentMethodDetails: ['is missing']}) - end - - it 'must be a JSON object' do - generator.payment_method_details = 'not a json object' - is_expected.to dissatisfy_schema(schema, {paymentMethodDetails: ['must be a hash']}) + context 'eciIndicator' do + context 'when PAN_ONLY' do + it 'must not exist' do + token['eciIndicator'] = '05' + is_expected.to dissatisfy_schema(schema, {eciIndicator: ['cannot be defined']}) end end - end -end -describe Aliquot::Validator do - context 'ECv1' do - let(:generator) { AliquotPay.new(:ECv1) } - let(:token) { generator.token } - subject do - input - end + context 'when CRYPTOGRAM_3DS' do + before(:each) { generator.auth_method = 'CRYPTOGRAM_3DS' } - include_examples 'Validator Spec' + it 'is not required' do + token.delete('eciIndicator') + expect(JSON.parse(token.to_json, symbolize_names: false)).to satisfy_schema(schema) + end - context 'intermediateSigningKey' do - let(:schema) { Aliquot::Validator::TokenSchema } - let(:input) { token } + it 'must be a string' do + generator.eci_indicator = 123 + is_expected.to dissatisfy_schema(schema, {eciIndicator: ['must be a string']}) + end - it 'should not be enforced' do - token.delete('intermediateSigningKey') - expect(JSON.parse(input.to_json, symbolize_names: false)).to satisfy_schema(schema) + it 'must be an ECI' do + generator.eci_indicator = 'ff' + is_expected.to dissatisfy_schema(schema, {eciIndicator: ['must be an ECI']}) end end end +end - context 'ECv2' do - let(:generator) { AliquotPay.new(:ECv2) } - let(:token) { generator.token } +describe Aliquot::Validator do + describe 'ECv1' do subject do - input + token end - include_examples 'Validator Spec' - - context 'SignedKeySchema' do - let(:schema) { Aliquot::Validator::SignedKeySchema } - let(:input) { generator.build_signed_key } + describe 'non-tokenized' do + let(:schema) { Aliquot::Validator::ECv1_PaymentMethodDetailsContract.schema } + let(:generator) { AliquotPay.new(protocol_version: :ECv1, type: :browser) } + let(:token) { generator.token } + include_examples 'Validator Spec' - context 'keyExpiration' do + context 'pan' do it 'must exist' do - input.delete('keyExpiration') - is_expected.to dissatisfy_schema(schema, {keyExpiration: ['is missing']}) - end - - it 'must be filled' do - input['keyExpiration'] = '' - is_expected.to dissatisfy_schema(schema, {keyExpiration: ['must be filled']}) + token.delete('pan') + is_expected.to dissatisfy_schema(schema, {pan: ['is missing']}) end + end - it 'must be integer string' do - generator.key_expiration = 'not digits' - is_expected.to dissatisfy_schema(schema, {keyExpiration: ['must be string encoded integer']}) - end + context 'paymentMethod' do + let(:token) { generator.build_cleartext_message } + let(:schema) { Aliquot::Validator::EncryptedMessageSchema } + it 'must be CARD for everything but tokenized ECv1' do + generator.payment_method = 'RANDOM' + is_expected.to dissatisfy_schema(schema, {paymentMethod: ['must be equal to CARD']}) + end end - context 'keyValue' do - it 'must exist' do - input.delete('keyValue') - is_expected.to dissatisfy_schema(schema, {keyValue: ['is missing']}) - end + context 'intermediateSigningKey' do + let(:schema) { Aliquot::Validator::TokenSchema } + let(:input) { token } - it 'must be filled' do - input['keyValue'] = '' - is_expected.to dissatisfy_schema(schema, {keyValue: ['must be filled']}) - end - - it 'must be ec_public_key' do - generator.key_value = 'not EC public key' - is_expected.to dissatisfy_schema(schema, {keyValue: ['must be an EC public key']}) + it 'should not be enforced' do + token.delete('intermediateSigningKey') + expect(JSON.parse(input.to_json, symbolize_names: false)).to satisfy_schema(schema) end end end - context 'IntermediateSigningKeySchema' do - let(:schema) { Aliquot::Validator::IntermediateSigningKeySchema } - let(:input) { token['intermediateSigningKey'] } + describe 'tokenized' do + let(:schema) { Aliquot::Validator::ECv1_TokenizedPaymentMethodDetailsContract.schema } + let(:generator) { AliquotPay.new(protocol_version: :ECv1, type: :app) } + let(:token) { generator.token } + include_examples 'Validator Spec' - context 'signedKey' do + context 'dpan' do it 'must exist' do - token['intermediateSigningKey'].delete('signedKey') - is_expected.to dissatisfy_schema(schema, signedKey: ['is missing']) - end - - it 'must be filled' do - token['intermediateSigningKey']['signedKey'] = '' - is_expected.to dissatisfy_schema(schema, signedKey: ['must be filled']) + token.delete('pan') + is_expected.to dissatisfy_schema(schema, {pan: ['is missing']}) end + end - it 'must be a string' do - generator.signed_key_string = 122 - is_expected.to dissatisfy_schema(schema, signedKey: ['must be a string']) + context 'paymentMethod' do + let(:token) { generator.build_cleartext_message } + let(:schema) { Aliquot::Validator::EncryptedMessageSchema } + it 'must be TOKENIZED_CARD for tokenized ECv1' do + generator.payment_method = 'RANDOM' + generator.auth_method = '3DS' + token['protocolVersion'] = 'ECv1' + is_expected.to dissatisfy_schema(schema, {paymentMethod: ['must be equal to TOKENIZED_CARD']}) end + end - # CB: Not sure how to trigger this test as JSON.parse has changed since 2.3 - # see https://clearhaus.slack.com/archives/C3LG75WE9/p1661940442665459 - it 'must be valid json' + context 'intermediateSigningKey' do + let(:schema) { Aliquot::Validator::TokenSchema } + let(:input) { token } - it 'must pass' do + it 'should not be enforced' do + token.delete('intermediateSigningKey') expect(JSON.parse(input.to_json, symbolize_names: false)).to satisfy_schema(schema) end end + end + end - context 'signatures' do + context 'ECv2' do + subject do + token + end + context 'non-tokenized' do + let(:schema) { Aliquot::Validator::ECv2_PaymentMethodDetailsContract.schema } + let(:generator) { AliquotPay.new(protocol_version: :ECv2, type: :browser) } + let(:token) { generator.token } + include_examples 'Validator Spec' + include_examples 'ECv2 PaymentMethodDetails' + + context 'pan' do it 'must exist' do - input.delete('signatures') - is_expected.to dissatisfy_schema(schema, signatures: ['is missing']) - end - - it 'must be filled' do - generator.signatures = '' - is_expected.to dissatisfy_schema(schema, signatures: ['must be an array']) - end - - it 'must be an array' do - generator.signatures = 'Not an array' - is_expected.to dissatisfy_schema(schema, signatures: ['must be an array']) - end - - it 'entries must be base64' do - generator.signatures = ['Not base64'] - is_expected.to dissatisfy_schema(schema, signatures: {0 => ['must be Base64']}) - end - - it 'entries must be asn1' do - generator.signatures = ['Not base64'] - is_expected.to dissatisfy_schema(schema, signatures: {0 => ['must be Base64']}) + token.delete('pan') + is_expected.to dissatisfy_schema(schema, {pan: ['is missing']}) end + end - it 'must pass' do - expect(JSON.parse(input.to_json, symbolize_names: false)).to satisfy_schema(schema) - end + context 'paymentMethod' do + let(:token) { generator.build_cleartext_message } + let(:schema) { Aliquot::Validator::EncryptedMessageSchema } + it 'must be CARD for everything but tokenized ECv1' do + generator.payment_method = 'RANDOM' + is_expected.to dissatisfy_schema(schema, {paymentMethod: ['must be equal to CARD']}) + end end end - context 'intermediateSigningKey' do - let(:schema) { Aliquot::Validator::TokenSchema } - let(:input) { token } - it 'must exist' do - token.delete('intermediateSigningKey') - is_expected.to dissatisfy_schema(schema, {intermediateSigningKey: ['is missing']}) + context 'tokenized' do + let(:schema) { Aliquot::Validator::ECv2_TokenizedPaymentMethodDetailsContract.schema } + let(:generator) { AliquotPay.new(protocol_version: :ECv2, type: :browser) } + let(:token) { generator.token } + # include_examples 'Validator Spec' + include_examples 'ECv2 PaymentMethodDetails' + + context 'pan' do + it 'must exist' do + token.delete('pan') + is_expected.to dissatisfy_schema(schema, {pan: ['is missing']}) + end end - it 'must be a JSON object' do - generator.intermediate_signing_key = 'Not a JSON object' - is_expected.to dissatisfy_schema(schema, {intermediateSigningKey: ['must be a hash']}) + context 'paymentMethod' do + let(:token) { generator.build_cleartext_message } + let(:schema) { Aliquot::Validator::EncryptedMessageSchema } + it 'must be CARD for everything but tokenized ECv1' do + generator.payment_method = 'RANDOM' + is_expected.to dissatisfy_schema(schema, {paymentMethod: ['must be equal to CARD']}) + end end end end