Handle Google Wallet callbacks with NodeJS

Joris Verbogt
Joris Verbogt
Jul 8 2022
Posted in Engineering & Technology

Keep track of items being added or removed

Handle Google Wallet callbacks with NodeJS

The Google Wallet API (formerly known as Google Pay) is a very powerful and extensive tool to create and issue Loyalty Cards, Gift Cards, Event Tickets and Transit Passes. It is also very poorly supported in terms of developer libraries, especially for NodeJS.

Callbacks

One useful feature of the Google Wallet API is the ability to be called back by Google whenever a user adds or removes a wallet item to or from their wallet. These callbacks happen as HTTP POST requests to an endpoint as defined in the wallet class object the item belongs to.

The Google Documentation is very brief about how to process the data, as it is cryptographically signed to be able to verify authenticity of the callback message. These guides recommend using the Tink library, which is not available for NodeJS.

Luckily, with some research into that library and by using the example from the docs, it is possible to do this using basic crypto methods available in NodeJS.

Handle the call

To handle the call, our web server needs to respond to the endpoint URL as defined in the wallet class. The exact setup is beyond this blog post, but let's assume we have a request handler function that receives the incoming request:

const paymentTokenRecipient = new PaymentTokenRecipient() // <-- we'll create this utility class in a moment

function handleCallback(request, response, next) {
  paymentMethodTokenRecipient.unseal(ISSUER_ID, request.body).then((message) => {
    // payment token verified
    try {
      const signedMessageObject = JSON.parse(message)
      if (signedMessageObject.objectId) {
        const [issuerId, objectId] = signedMessageObject.objectId.split('.')
        if (!issuerId || issuerId !== ISSUER_ID || !objectId) {
          // invalid payload, request discarded
          response.status(200).send({ message: 'invalid payload, request discarded' })
        } else if (signedMessageObject.eventType === 'save') {
          
          // handle the save to wallet action for objectId
          
          response.status(200).send({ message: 'request accepted' })
        } else if (signedMessageObject.eventType === 'del') {
          
          // handle the remove from wallet action for objectId
          
          response.status(200).send({ message: 'request accepted' })
        } else {
          response.status(200).send({ message: 'request accepted' })
        }
      }
    } catch(err) {
      response.status(200).send({ message: 'invalid message, request discarded' })
    }
  }).catch((err) => {
    response.status(200).send({ message: 'invalid signature, request discarded' })
  })
}

That was the easy part, now for the crypto part.

Set up a utility class

Let's create the PaymentMethodTokenRecipient class from the example above, and add a few methods to be able to retrieve a fresh set of current public keys that Google uses to sign the callback messages.

import crypto from 'crypto'
import got from 'got'

const GOOGLE_PUBLIC_KEY_URL = 'https://pay.google.com/gp/m/issuer/keys'
const GOOGLE_SENDER_ID = 'GooglePayPasses'
const EXPIRATION_INTERVAL = 3600 // check 1 hour before key expires
const PROTOCOL_VERSION = 'ECv2SigningOnly'

export default class PaymentMethodTokenRecipient {
  constructor() {
    this.googlePublicKeys = []
    this.publicKeyExpiration = new Date()
  }

  /**
   * Fetch google public keys
   * @return {Promise<Response<unknown>>}
   * @private
   */
  async _fetchPublicKeys() {
    // use the got npm package here, but this could be any HTTP request module
    const response = await got(GOOGLE_PUBLIC_KEY_URL, {
      method: 'get',
      responseType: 'json',
      timeout: {
        request: 10000
      },
      throwHttpErrors: false
    })
    if (response.statusCode !== 200) {
      throw new Error('could not fetch public keys')
    } else {
      return response
    }
  }

  /**
   * Refresh public keys if necessary
   * @return {Promise<void>}
   * @private
   */
  async _refreshPublicKeys() {
    if (!this.googlePublicKeys?.length || this.publicKeyExpiration <= new Date()) {
      const result = await this._fetchPublicKeys()
      if (Array.isArray(result.body?.keys)) {
        let publicKeyExpiration
        let googlePublicKeys = []
        for (const key of result.body.keys) {
          googlePublicKeys.push(Buffer.from(key.keyValue, 'base64'))
          const keyExpiration = new Date(key.keyExpiration)
          if (!publicKeyExpiration || keyExpiration < publicKeyExpiration) {
            publicKeyExpiration = keyExpiration
          }
        }
        this.googlePublicKeys = googlePublicKeys
        this.publicKeyExpiration = new Date(publicKeyExpiration)
        this.publicKeyExpiration.setUTCSeconds(this.publicKeyExpiration.getUTCSeconds() - EXPIRATION_INTERVAL)
      }
    }
  }
}

Verify the signature

Google signs the message formatted as a length-value byte array, so let's add a helper method for that:

  /**
   * Create buffer from message chunks prefixed with their byte length
   * @param chunks
   * @return {Buffer}
   */
  toLengthValue(...chunks) {
    const buffers = []
    for (const chunk of chunks) {
      const value = Buffer.from(chunk)
      const length = Buffer.alloc(4)
      length.writeInt32LE(value.byteLength)
      buffers.push(length)
      buffers.push(value)
    }
    return Buffer.concat(buffers)
  }

Messages are signed with Elliptic-Curve Digital Signature Algorithm and hashed with SHA-256, let's use the NodeJS crypto module to verify:

  /**
   * Verify a message with ECDSA-SHA256
   * @param protocolVersion
   * @param publicKey
   * @param signature
   * @param signedBytes
   * @return {boolean}
   */
  verify(protocolVersion, publicKey, signature, signedBytes) {
    return crypto.verify('sha256', signedBytes, {key: publicKey, format: 'der', type: 'spki', dsaEncoding: 'der'}, signature)
  }

Messages are signed with an intermediate signing key, let's verify this key first against all possible Google public keys:

  /**
   * Verify intermediate signing key signatures against Google public keys
   * @param jsonMsg
   * @return {Buffer}
   */
  verifyIntermediateSigningKey(jsonMsg) {
    const intermediateSigningKey = jsonMsg.intermediateSigningKey
    const signedKeyAsString = intermediateSigningKey.signedKey
    const signatures = []
    for (const signature of intermediateSigningKey.signatures) {
      signatures.push(Buffer.from(signature, 'base64'))
    }
    const signedBytes = this.toLengthValue(GOOGLE_SENDER_ID, PROTOCOL_VERSION, signedKeyAsString)
    let verified = false
    for (const publicKey of this.googlePublicKeys) {
      for (const signature of signatures) {
        if (this.verify(PROTOCOL_VERSION, publicKey, signature, signedBytes)) {
          verified = true
        }
      }
    }
    if (verified) {
      const signedKey = JSON.parse(signedKeyAsString)
      if (signedKey.keyExpiration > new Date().getTime()) {
        return Buffer.from(signedKey.keyValue, 'base64')
      } else {
        throw new Error('intermediate signing key expired')
      }
    } else {
      throw new Error('could not verify intermediate signing key')
    }
  }

And then use this intermediate signing key to verify the actual message payload:

  /**
   * Verify message with ECDSA-SHA256 against intermediate signing key
   * @param recipientId
   * @param jsonMsg
   * @return {*|string}
   */
  verifyECV2(recipientId, jsonMsg) {
    const signature = Buffer.from(jsonMsg.signature, 'base64')
    const signedMessage = jsonMsg.signedMessage
    const signedBytes = this.toLengthValue(GOOGLE_SENDER_ID, recipientId, PROTOCOL_VERSION, signedMessage)
    const verified = this.verify(PROTOCOL_VERSION, this.verifyIntermediateSigningKey(jsonMsg), signature, signedBytes)
    if (verified) {
      return signedMessage
    } else {
      throw new Error('could not verify message')
    }
  }

Finally, let's combine the public keys refresh and the crypto methods to unseal our incoming callback message:

  /**
   * Unseal signed mesage
   * @param issuerId
   * @param message
   * @return {Promise<*|string>}
   */
  async unseal(issuerId, message) {
    try {
      await this._refreshPublicKeys()
      return this.verifyECV2(issuerId, message)
    } catch(err) {
      throw new Error('could not verify message')
    }
  }

Conclusion

Even though Google does not do much effort in supporting NodeJS developers when using the Google Wallet API, with the help of the above examples it should be pretty easy to set up your own callback handling service.

If you do not want to go through all the hassle of creating, distributing, managing and monitoring your Google Wallet items (and you shouldn't!), please take a look at the latest Notificare Loyalty feature, which handles all of this (and more) for you, out of the box! It also integrates Apple Wallet and Google Wallet into one comprehensive package, so you got both technologies covered in one platform.

If you would to experience how this all works, check out our new Demo App and create your trial account today to see all of the above in action.

As always, we hope you liked this article and if you have anything to add, we are available via our Support Channel.

Keep up-to-date with the latest news