Protect Twilio Voice Input with Encryption and Redaction

Protect Twilio Voice Input with Encryption and Redaction
January 10, 2024
Written by
Reviewed by
Paul Kamp
Twilion

Are you doing all you can to protect the sensitive information your callers trust you with?

As organizations leverage more sensitive information, securing that data is more important than ever. Twilio offers numerous ways you can protect your sensitive data—but it’s up to you to implement the resources Twilio provides responsibly.

In this article, learn how to encrypt and redact data collected from Twilio Programmable Voice, using <Gather> TwiML with Twilio Serverless Functions and Voice PCI Mode.

Things you'll need

In order to follow this tutorial, you will need:

What are you building?

You will build a simple interactive voice application to handle caller authentication. A Function will be used to prompt the caller for the following sensitive information via <Gather> TwiML.

  1. "Please enter your 4 digit PIN"
  2. "Please enter the last 4 digits of your payment card number"

As soon as this information is received from the caller, it will be encrypted. From that moment on, the data will remain encrypted until it reaches its destination.

In a real-world implementation, the destination would likely be your backend service for processing. But here, another Function will act as a “dummy API” to demonstrate how the decryption would be performed.

You will also enable Voice PCI Mode to redact gathered information in Voice call logs.

The Before

Before jumping into the solution, take a look at what your logs would look like without encryption or redaction.

Twilio Functions will log any error generated from a Function to your Twilio Debugger. In this example scenario, you will log an error if certain specific digits are not entered. You can see the plain-text request parameters in the error received by the Debugger.

Plain-text request parameters (4 digit PIN and last 4 digits of credit card) from Twilio Function debugger error log

Programmable Voice will also log the digits collected in plain-text in the Voice call log:

Plain-text digits from Gather TwiML in the Twilio Voice Call log

You can find this information if you have access to Call Logs or the Debugger.

The After

The data visible after implementing this solution is less vulnerable. By the end, your Function log will show more secure, encrypted values:

Encrypted request parameters (32-character encrypted PIN and encrypted last 4 digits of card) from Twilio Function debugger error log

And your Call log will show *REDACTED*:

Redacted digits from Gather TwiML in Voice Call log

Get Started

Twilio Functions

To follow along with these instructions, use the Twilio Console’s Function Editor.

Advanced developers should consider using the more robust Serverless CLI to create, deploy, and maintain Functions.

Create a Service

Functions are created and contained within Services:

  1. Log in to the Twilio Console and navigate to the Functions tab.
  2. Create a Service by clicking the Create Service button and adding a name such as encrypted-gather-sample.

Add Dependency

In this solution, the axios library is used to make a request to your “pretend” backend service (the decrypt-gather Function) for processing.

Add axios as a dependency to your Service.

Adding "axios" as a dependency to the Twilio Serverless Service via Twilio Console UI

Create an Environment Variable

This solution requires a secret key, which will be used to encrypt and decrypt the sensitive data.

Your secret key string must be at least 32 bytes in length. Keep this secret private.

To create a random secret, the following command line can be used with Mac/Linux:

xxd -l32 -p /dev/urandom

Alternatively, this secret can be generated through Node.js:

crypto.randomBytes(32).toString('hex')

Add an Environment Variable within your Service that stores your key.

Adding secret key as an Environment Variable in the Twilio Serverless Service via Twilio Console UI

For testing purposes, the following 32-byte secret key can be used.

a154eb4c759711bc2538a7cc021e9e9f17dd8aa63151c62ca28a82a4a404203d

Create AES Encryption Function

First, create a Function to handle encryption and decryption of data using symmetric-key cryptography.

Node.js Crypto

Node.js offers a built-in cryptography module called Crypto. Crypto provides several useful methods, like createCipheriv() and createDecipheriv() which allow us to specify what kind of block-cipher algorithm to employ.

GCM Block Cipher

Advanced Encryption Standard, known as AES, is a technique for protecting data using encryption algorithms. AES can be achieved through a variety of modes of operations.

In this solution, you’ll be using GCM, Galois/Counter Mode, a symmetric-key cryptographic block cipher which is preferred for its speed and strength.

Code

Create a new Function called AES with the following code.

const crypto = require("crypto")

const ALGORITHM = {
    BLOCK_CIPHER: "aes-256-gcm",
    AUTH_TAG_BYTE_SIZE: 16, 
    IV_BYTE_SIZE: 12,  
}

exports.encrypt = (plainText, key) => {
    const nonce = crypto.randomBytes(ALGORITHM.IV_BYTE_SIZE)
    const cipher = crypto.createCipheriv(
        ALGORITHM.BLOCK_CIPHER, 
        Buffer.from(key, 'hex'), 
        nonce, 
        {
            authTagLength: ALGORITHM.AUTH_TAG_BYTE_SIZE
        }
    )

    const cipherText = Buffer.concat([
        nonce,
        cipher.update(plainText),
        cipher.final(),
        cipher.getAuthTag()
    ])

    return cipherText.toString('hex')
}

exports.decrypt = (cipherText, key) => {
    cipherText = Buffer.from(cipherText, 'hex')

    const authTag = cipherText.slice(-16)
    const nonce = cipherText.slice(0, 12)
    const encryptedMessage = cipherText.slice(12, -16)

    const decipher = crypto.createDecipheriv(
        ALGORITHM.BLOCK_CIPHER, 
        Buffer.from(key), 
        nonce, 
        {
            authTagLength: ALGORITHM.AUTH_TAG_BYTE_SIZE
        }
    )

    decipher.setAuthTag(authTag)
    const decrypted = decipher.update(encryptedMessage, '', 'utf8') + decipher.final('utf8')      
    return decrypted 
}

This Function should be set to a visibility of "Private", as it will only be used from within another Function in the same Service.

Private Serverless Function visibility setting from the Twilio Console UI

Create encrypted-gather Function

Next, create the Function that will perform the sensitive <Gather> operations. This Function will be configured as the incoming Phone Number voice webhook in a later step.

From this Function, the digits entered by the caller will be encrypted as soon as they are received, and sent in their encrypted state to the final, “destination” Function.

Code

Create a new Function called encrypted-gather with the following code:

const axios = require('axios')
const AES = require(Runtime.getFunctions()['AES'].path)

exports.handler = async function (context, event, callback) {
    const twiml = new Twilio.twiml.VoiceResponse()

    const secret_key = context.AES_SECRET

    const functionUrl = `https://${context.DOMAIN_NAME}/encrypted-gather`
    const dummyApi = `https://${context.DOMAIN_NAME}/decrypt-gather`

    const step = event.step || "getLast4CC"

    switch (step) {
        case ("getLast4CC"):
            gatherLast4Card(twiml, functionUrl);
            break
        case ("getPin"):
            let encryptedCardDigits = AES.encrypt(event.Digits, secret_key)
            gatherPin(twiml, encryptedCardDigits, functionUrl)
            break
        case ("processData"):
            let encryptedPinDigits = AES.encrypt(event.Digits, secret_key)
            await processGatheredData(twiml, event.encryptedCardDigits, encryptedPinDigits, dummyApi)
            break
    }

    return callback(null, twiml)
}

const gatherLast4Card = (twiml, functionUrl) => {
    const gather = twiml.gather({
        action: `${functionUrl}?step=getPin`,
        method: 'POST',
        input: 'dtmf',
        timeout: 10,
        numDigits: 4,
    });
    gather.say('Please enter last 4 digits of your payment card number.');

    return gather
}

const gatherPin = (twiml, encryptedCardDigits, functionUrl) => {
    const gather = twiml.gather({
        action: `${functionUrl}?step=processData&encryptedCardDigits=${encryptedCardDigits}`,
        method: 'POST',
        input: 'dtmf',
        timeout: 10,
        numDigits: 4,
    });
    gather.say('Please enter your unique 4 digit identification number');

    return gather
}

const processGatheredData = async (twiml, encryptedCardDigits, encryptedPinDigits, dummy_url) => {
    // make request to "dummy" api endpoint - example decrypt function
    try {
        const apiResponse = await axios({
            method: 'post',
            url: dummy_url,
            data: {
                encryptedCardDigits, encryptedPinDigits
            }
        })

        twiml.say(`Thank you. Your account number is ${apiResponse.data.account} and your balance is ${apiResponse.data.balance}`)
    }
    catch (e) {
        twiml.say(`We were not able to locate you in our system. Goodbye.`)
    }

    return twiml
}

This Function should be set to "Protected", as it will be called from within Twilio and can be secured with the X-Twilio-Signature header.  

Protected Serverless Function visibility setting from the Twilio Console UI

When implementing this solution in production, you’ll need to change the decryption “dummyApi” variable to the URL of your backend service.

const dummyApi = `https://${context.DOMAIN_NAME}/decrypt-gather`

How is it encrypting?

At the top, you import the functions created in the previous step with the following line:

const AES = require(Runtime.getFunctions()['AES'].path)

Then, you define your secret by getting it from the environment variable:

const secret_key = context.AES_SECRET

And, most importantly, any sensitive information is wrapped with the encrypt function. (In this case, <Gather>'d information is passed as the Digit parameter, and can be accessed from the event object.)

 let encryptedCardDigits = AES.encrypt(event.Digits, secret_key)

This handles the encryption of the gathered information.

Create decrypt-gather Function

Finally, let’s create a Function to demonstrate how to decrypt the sensitive data.

In a production environment, this would likely be a request to your backend service that processes the caller information based on your business needs.

In this solution, a third Function will act as the “backend service” that processes this data. This Function will receive the encrypted digits and decrypt them for further processing.

Code

Create a new Function called decrypt-gather with the following code:

const AES = require(Runtime.getFunctions()['AES'].path)

exports.handler = function(context, event, callback) {
    const response = new Twilio.Response()
    const secret_key = context.AES_SECRET

    const last4card = AES.decrypt(event.encryptedCardDigits, secret_key)
    const pin = AES.decrypt(event.encryptedPinDigits, secret_key)

   //hard-coded values used for testing purposes
    if (last4card === "1234" && pin === "4321") {
        response.setBody(JSON.stringify({
            account: "AC12345678",
            balance: "12.55"
        }))
    } else {
        response.setStatusCode(404)
        response.setBody("No data found")
    }

    return callback(null, response)
}

This Function’s visibility will be "Public", as it is pretending to be an external service.

Public Serverless Function visibility setting from the Twilio Console UI

How is it decrypting?

At the top, you import AES functions again and define the secret_key as a variable.

Then you call decrypt on the information that was previously encrypted:

const last4card = AES.decrypt(event.encryptedCardDigits, secret_key)

Additional Configuration

Phone Number Webhook

For the sake of simplicity, connect this Function directly to a Phone Number.

To configure the Phone Number:

  1. From the Twilio Console, navigate to the Phone Numbers section
  2. Select your phone number, then scroll to the Voice & Fax section
  3. Set the encrypted-gather Function as the A call comes in webhook under Voice Configuration
  4. Save changes

Voice Configuration "A call comes in" webhook set to encrypted-gather Function

If you hope to trigger this from Twilio Studio, check out this blog post to learn more about how to incorporate this solution securely with Studio.

Enable PCI Mode

Almost done! You’ve secured the Functions, but there’s still one more area where Twilio retains gathered digits in plain-text – Voice call logs.

Below is a screenshot from the Twilio Console for an inbound call with the encrypted <Gather> solution implemented. Even though Functions secured the data, Voice hasn’t.

Plain-text digits from Gather TwiML in the Twilio Voice Call log

There’s only one way to prevent this data from being displayed in the Call log, and that’s with PCI Mode. Enabling PCI Mode on your account will redact all data captured from any <Gather> operation.

Redacted digits from Gather TwiML in Voice Call log

Enabling PCI Mode on an account is a one-way street. Once it’s on, you won’t be able to turn it off. Redaction may make troubleshooting Voice issues more challenging.

If you’re serious about capturing sensitive information securely…

  1. Navigate to the Twilio Voice Settings in the Twilio Console. (In the left navigation pane, click on Voice > Settings > General.)
  2. Click on the Enable PCI Mode button.
  3. Save changes.

Programmable Voice Setting for enabling PCI Mode in the Twilio Console UI

Make a call

Now it’s the moment of truth—it’s time to place a test call to the phone number.

From here, there are two paths to take.

If you enter 1234 as the last 4 digits of your “credit card” and 4321 as the unique PIN, you’ll hear some “dummy” account information returned on the call. This is an example of a successful API response.

If you enter any other digits, it will behave as though you aren’t a known user and return a 404 response. This is an example of an unsuccessful request, which will log an error to the Twilio Debugger.

How do I know it worked?

Follow the unsuccessful path and take a look at your Error log in the Twilio Console.

For the 404 error response, you’ll find an 82005 Error from Functions with the following details:

Encrypted request parameters (32-character encrypted PIN and encrypted last 4 digits of card) from Twilio Function debugger error log

This is good. Without the encryption, an unsuccessful response would have logged those variables in plain-text. But now the data will log in its safer, encrypted form.

You can also check your Call log to confirm the digits show *REDACTED* there as well.

Is this secure?

Following this tutorial (including the optional PCI Mode steps) would prevent the data from logging in plain-text anywhere within Twilio’s ecosystem, and it would prevent anyone at Twilio from being able to decrypt your sensitive data – making this an improvement over the default.

However, the secret key used for encryption and decryption is stored as an Environment Variable on the Service, meaning users to whom you grant Twilio Functions access would be able to extract the key and potentially go through the effort to decrypt the values.

Final Recommendation

If you are making modifications to the sample code provided, please keep in mind that Functions retain console warnings and errors within internal Twilio systems and in the Twilio Debugger for some time.

Do not use any of the following console logging methods with any sensitive, unencrypted data:

console.log()
console.warn()
console.error()

Conclusion

In this lesson, you learned how you can protect data collected from <Gather> TwiML with encryption via a Serverless Function and redaction through Voice PCI Mode.

If you want to collect payments from your callers, consider the fully PCI-compliant Twilio <Pay> feature.

To learn more about PCI compliance at Twilio, check out the documentation and responsibility matrix.

Users trust you to keep their sensitive information private. Make sure you respect and retain that trust by doing all you can to secure the data you process.

Bry Schinina is a developer and educator who deeply appreciates when companies don’t expose private information. She works as Tech Lead and Sr. Technical Account Manager at Twilio, solving complex problems and helping organizations succeed with their digital engagement platform. She can be reached at bschinina [at] twilio.com.