class Aliquot::Payment

A Payment represents a single payment using Google Pay. It is used to verify/decrypt the supplied token by using the shared secret, thus avoiding having knowledge of any private keys involved.

Constants

SUPPORTED_PROTOCOL_VERSIONS

Public Class Methods

new(token_string, shared_secret, recipient_id, signing_keys: ENV['GOOGLE_SIGNING_KEYS']) click to toggle source

Parameters:

token_string

Google Pay token (JSON string)

shared_secret

Base64 encoded shared secret

recipient_id

Google Pay recipient ID

signing_keys

Signing keys fetched from Google

# File lib/aliquot/payment.rb, line 22
def initialize(token_string, shared_secret, recipient_id,
               signing_keys: ENV['GOOGLE_SIGNING_KEYS'])

  begin
    validation = Aliquot::Validator::Token.new(JSON.parse(token_string))
    validation.validate
  rescue JSON::JSONError => e
    raise InputError, "token JSON is invalid, #{e.message}"
  end

  @token = validation.output

  @shared_secret = shared_secret
  @recipient_id   = recipient_id
  @signing_keys  = signing_keys
end

Public Instance Methods

check_shared_secret() click to toggle source
# File lib/aliquot/payment.rb, line 111
def check_shared_secret
  begin
    decoded = Base64.strict_decode64(@shared_secret)
  rescue
    raise InvalidSharedSecretError, 'shared_secret must be base64'
  end

  raise InvalidSharedSecretError, 'shared_secret must be 32 bytes when base64 decoded' unless decoded.length == 32
end
check_signature() click to toggle source
# File lib/aliquot/payment.rb, line 121
def check_signature
  signed_string_message = ['Google', @recipient_id, protocol_version, @token[:signedMessage]].map do |str|
    [str.length].pack('V') + str
  end.join
  message_signature = Base64.strict_decode64(@token[:signature])

  root_signing_keys = root_keys

  case protocol_version
  when 'ECv1'
    # Check if signature was performed directly with any possible key.
    success =
      root_signing_keys.map do |key|
        key.verify(new_digest, message_signature, signed_string_message)
      end.any?

    raise InvalidSignatureError, 'signature of signedMessage does not match' unless success
  when 'ECv2'
    signed_key_signature = ['Google', 'ECv2', @token[:intermediateSigningKey][:signedKey]].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_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]

    # Check that a root signing key signed the intermediate
    success = valid_intermediate_key_signatures?(
      root_signing_keys,
      intermediate_signatures,
      signed_key_signature
    )

    raise InvalidSignatureError, 'no valid signature of intermediate key' unless success
  end
rescue OpenSSL::PKey::PKeyError => e
  # Catches problems with verifying signature. Can be caused by signature
  # being valid ASN1 but having invalid structure.
  raise InvalidSignatureError, "error verifying signature, #{e.message}"
end
compare(a, b) click to toggle source
# File lib/aliquot/payment.rb, line 247
def compare(a, b)
  return false unless a.length == b.length

  diffs = 0

  ys = b.unpack('C*')

  a.each_byte do |x|
    diffs |= x ^ ys.shift
  end

  diffs.zero?
end
decrypt(key, encrypted) click to toggle source
# File lib/aliquot/payment.rb, line 211
def decrypt(key, encrypted)
  c = new_cipher
  c.key = key
  c.decrypt

  c.update(Base64.strict_decode64(encrypted)) + c.final
end
derive_keys(ephemeral_public_key, shared_secret, info) click to toggle source

Keys are derived according to the Google Pay specification.

# File lib/aliquot/payment.rb, line 189
def derive_keys(ephemeral_public_key, shared_secret, info)
  input_keying_material = Base64.strict_decode64(ephemeral_public_key) + Base64.strict_decode64(shared_secret)

  key_len = new_cipher.key_len

  key_bytes = if OpenSSL.const_defined?(:KDF) && OpenSSL::KDF.respond_to?(:hkdf)
                OpenSSL::KDF.hkdf(input_keying_material, hash: new_digest, salt: '', length: 2 * key_len, info: info)
              else
                HKDF.new(input_keying_material, algorithm: 'SHA256', info: info).next_bytes(2 * key_len)
              end

  [key_bytes[0..key_len - 1], key_bytes[key_len..2 * key_len]]
end
expired?() click to toggle source

Check if the token is expired, according to the messageExpiration included in the token.

# File lib/aliquot/payment.rb, line 230
def expired?
  @message[:messageExpiration].to_f / 1000.0 <= Time.now.to_f
end
intermediate_key_expired?() click to toggle source
# File lib/aliquot/payment.rb, line 100
def intermediate_key_expired?
  cur_millis = (Time.now.to_f * 1000).round
  @intermediate_key[:keyExpiration].to_i < cur_millis
end
new_cipher() click to toggle source
# File lib/aliquot/payment.rb, line 234
def new_cipher
  case protocol_version
  when 'ECv1'
    OpenSSL::Cipher::AES128.new(:CTR)
  when 'ECv2'
    OpenSSL::Cipher::AES256.new(:CTR)
  end
end
new_digest() click to toggle source
# File lib/aliquot/payment.rb, line 243
def new_digest
  OpenSSL::Digest::SHA256.new
end
process() click to toggle source

Validate and decrypt the token.

# File lib/aliquot/payment.rb, line 41
def process
  unless valid_protocol_version?
    raise Error, "supported protocol versions are #{SUPPORTED_PROTOCOL_VERSIONS.join(', ')}"
  end

  @recipient_id = validate_recipient_id

  check_shared_secret

  if protocol_version == 'ECv2'
    @intermediate_key = validate_intermediate_key
    raise InvalidSignatureError, 'intermediate certificate is expired' if intermediate_key_expired?
  end

  check_signature

  @signed_message = validate_signed_message

  begin
    aes_key, mac_key = derive_keys(@signed_message[:ephemeralPublicKey], @shared_secret, 'Google')
  rescue => e
    raise KeyDerivationError, "cannot derive keys, #{e.message}"
  end

  raise InvalidMacError, 'MAC does not match' unless valid_mac?(mac_key)

  begin
    @message = JSON.parse(decrypt(aes_key, @signed_message[:encryptedMessage]))
  rescue JSON::JSONError => e
    raise InputError, "encryptedMessage JSON is invalid, #{e.message}"
  rescue => e
    raise DecryptionError, "decryption failed, #{e.message}"
  end

  @message = validate_message

  raise TokenExpiredError, 'token is expired' if expired?

  @message
end
protocol_version() click to toggle source
# File lib/aliquot/payment.rb, line 82
def protocol_version
  @token[:protocolVersion]
end
root_keys() click to toggle source
# File lib/aliquot/payment.rb, line 164
def root_keys
  root_signing_keys = JSON.parse(@signing_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
valid_intermediate_key_signatures?(signing_keys, signatures, signed) click to toggle source
# File lib/aliquot/payment.rb, line 174
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
end
valid_mac?(mac_key) click to toggle source
# File lib/aliquot/payment.rb, line 203
def valid_mac?(mac_key)
  data = Base64.strict_decode64(@signed_message[:encryptedMessage])
  tag = @signed_message[:tag]
  mac = OpenSSL::HMAC.digest(new_digest, mac_key, data)

  compare(Base64.strict_encode64(mac), tag)
end
valid_protocol_version?() click to toggle source
# File lib/aliquot/payment.rb, line 86
def valid_protocol_version?
  SUPPORTED_PROTOCOL_VERSIONS.include?(protocol_version)
end
validate_intermediate_key() click to toggle source
# File lib/aliquot/payment.rb, line 90
def validate_intermediate_key
  # Valid JSON as it has been checked by Token Validator.
  intermediate_key = JSON.parse(@token[:intermediateSigningKey][:signedKey])

  validator = Aliquot::Validator::SignedKeyValidator.new(intermediate_key)
  validator.validate

  validator.output
end
validate_message() click to toggle source
# File lib/aliquot/payment.rb, line 219
def validate_message
  validator = Aliquot::Validator::EncryptedMessageValidator.new(@message)
  validator.validate

  # Output is hashed with symbolized keys.
  validator.output
end
validate_recipient_id() click to toggle source
# File lib/aliquot/payment.rb, line 105
def validate_recipient_id
  raise InvalidRecipientIDError, 'recipient_id must be alphanumeric and punctuation' unless /\A[[:graph:]]+\z/ =~ @recipient_id

  @recipient_id
end
validate_signed_message() click to toggle source
# File lib/aliquot/payment.rb, line 181
def validate_signed_message
  signed_message = @token[:signedMessage]
  validator = Aliquot::Validator::SignedMessage.new(JSON.parse(signed_message))
  validator.validate
  validator.output
end