Skip to content

Commit

Permalink
CLIENT-7583 STIR/SHAKEN (#150)
Browse files Browse the repository at this point in the history
* CLIENT-7583 | Surface CallerInfo from STIR-SHAKEN

* Fix typo
  • Loading branch information
liberty-rowland authored Jun 5, 2020
1 parent 570fc3d commit b4485de
Show file tree
Hide file tree
Showing 10 changed files with 289 additions and 3 deletions.
28 changes: 27 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,31 @@
1.12.0 (In progress)
====================

New Features
---------

### CallerInfo

* Added a `Connection.callerInfo` field that will hold useful information about the caller when the
caller is a PSTN number. Currently, one new field is available:
1. `CallerInfo.isVerified` -- A boolean indicating whether or not Twilio was able to verify
whether the caller is authorized to use the number that this call is from. If true, it
is safe to trust that the caller is who they claim to be. If this is false, it does not
mean that the call is fraudulent, only that Twilio was not able to verify authenticity.
Most legitimate calls at the time of implementation will not be verifiable as most
numbers are not set up to utilize the underlying STIR/SHAKEN protocol.

#### Example
```ts
device.on('incoming', connection => {
if (connection.callerInfo && connection.callerInfo.isVerified) {
showVerifiedBadge();
}
});
```

1.11.0 (May 21, 2020)
===================
=====================

New Features
---------
Expand Down
2 changes: 2 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,7 @@ prod:
account_sid: ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
auth_token: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
app_sid: APxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
app_sid_stir: APxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
caller_id: +xxxxxxxxxx
api_key_secret: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
api_key_sid: SKxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ services:
- ACCOUNT_SID=${ACCOUNT_SID}
- AUTH_TOKEN=${AUTH_TOKEN}
- APPLICATION_SID=${APPLICATION_SID}
- APPLICATION_SID_STIR=${APPLICATION_SID_STIR}
- API_KEY_SECRET=${API_KEY_SECRET}
- API_KEY_SID=${API_KEY_SID}
- BVER=${BVER}
- CALLER_ID=${CALLER_ID}
image: twilio-client:1.0.0
build:
args:
Expand Down
2 changes: 2 additions & 0 deletions karma.conf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ if (fs.existsSync(__dirname + '/config.yaml')) {
process.env.API_KEY_SID = process.env.API_KEY_SID || creds.api_key_sid;
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || creds.api_key_secret;
process.env.APPLICATION_SID = process.env.APPLICATION_SID || creds.app_sid;
process.env.APPLICATION_SID_STIR = process.env.APPLICATION_SID_STIR || creds.app_sid_stir;
process.env.CALLER_ID = process.env.CALLER_ID || creds.caller_id;
process.env.AUTH_TOKEN = process.env.AUTH_TOKEN || creds.auth_token;
}

Expand Down
29 changes: 29 additions & 0 deletions lib/twilio/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,12 @@ class Connection extends EventEmitter {
*/
static toString = () => '[Twilio.Connection class]';

/**
* Describes the phone number that is initiating the call.
* Null if this call is outbound from this Client, or if the caller is not from PSTN.
*/
readonly callerInfo: Connection.CallerInfo | null;

/**
* The custom parameters sent to (outgoing) or received by (incoming) the TwiML app.
*/
Expand Down Expand Up @@ -261,6 +267,15 @@ class Connection extends EventEmitter {

this._direction = this.parameters.CallSid ? Connection.CallDirection.Incoming : Connection.CallDirection.Outgoing;

if (this._direction === Connection.CallDirection.Incoming && this.parameters) {
const isFromPSTN: boolean = /^\+?[\d-\(\) ]+$/.test(this.parameters.From);
this.callerInfo = isFromPSTN
? { isVerified: this.parameters.StirStatus === 'TN-Validation-Passed-A' }
: null;
} else {
this.callerInfo = null;
}

this._mediaReconnectBackoff = Backoff.exponential(BACKOFF_CONFIG);
this._mediaReconnectBackoff.on('ready', () => this.mediaStream.iceRestart());

Expand Down Expand Up @@ -1521,6 +1536,20 @@ namespace Connection {
twilioError?: TwilioError;
}

/**
* Describes the phone number that initiated an incoming call.
*/
export interface CallerInfo {
/**
* Whether or not Twilio was able to verify whether the caller is authorized to use the number
* that this call is from. If true, it is safe to trust that the caller is who they claim to be.
* If this is false, it does not mean that the call is faked, only that we were not able to
* verify authenticity. Most legitimate calls at the time of implementation will not be
* verifiable.
*/
isVerified: boolean;
}

/**
* Mandatory config options to be passed to the {@link Connection} constructor.
* @private
Expand Down
6 changes: 6 additions & 0 deletions tests/env.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
const processEnv = {
ACCOUNT_SID: process.env.ACCOUNT_SID,
APPLICATION_SID: process.env.APPLICATION_SID,
APPLICATION_SID_STIR: process.env.APPLICATION_SID_STIR,
CALLER_ID: process.env.CALLER_ID,
API_KEY_SID: process.env.API_KEY_SID,
API_KEY_SECRET: process.env.API_KEY_SECRET,
AUTH_TOKEN: process.env.AUTH_TOKEN,
Expand All @@ -14,6 +16,8 @@ const processEnv = {
const env = [
['ACCOUNT_SID', 'accountSid'],
['APPLICATION_SID', 'appSid'],
['APPLICATION_SID_STIR', 'appSidStir'],
['CALLER_ID', 'callerId'],
['API_KEY_SECRET', 'apiKeySecret'],
['API_KEY_SID', 'apiKeySid'],
['AUTH_TOKEN', 'authToken'],
Expand All @@ -28,6 +32,8 @@ const env = [
[
'accountSid',
'appSid',
'appSidStir',
'callerId',
'apiKeySid',
'apiKeySecret',
'authToken',
Expand Down
5 changes: 5 additions & 0 deletions tests/integration/device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ describe('Device', function() {
}, 3000);
});

it('should set callerInfo to null on both connections', () => {
assert.equal(connection1!.callerInfo, null);
assert.equal(connection2!.callerInfo, null);
});

it('should be using the PCMU codec for both connections', (done) => {
let codec1: string | null | undefined = null;

Expand Down
79 changes: 79 additions & 0 deletions tests/integration/stirshaken.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import Connection from '../../lib/twilio/connection';
import Device from '../../lib/twilio/device';
import { generateAccessToken } from '../lib/token';
import * as assert from 'assert';
import { EventEmitter } from 'events';
import * as env from '../env';

describe('STIR/Shaken', function() {
this.timeout(10000);

let device1: Device;
let device2: Device;
let identity1: string;
let identity2: string;
let options;
let token1: string;
let token2: string;

before(() => {
identity1 = 'id1-' + Date.now();
identity2 = 'aliceStir';
token1 = generateAccessToken(identity1, undefined, (env as any).appSidStir);
token2 = generateAccessToken(identity2, undefined, (env as any).appSidStir);
device1 = new Device();
device2 = new Device();

options = {
warnings: false,
};

return Promise.all([
expectEvent('ready', device1.setup(token1, options)),
expectEvent('ready', device2.setup(token2, options)),
]);
});

describe('device 1 calls device 2', () => {
before(done => {
device2.once(Device.EventName.Incoming, () => done());
(device1['connect'] as any)({ CallerId: (env as any).callerId });
});

describe('and device 2 accepts', () => {
let connection1: Connection;
let connection2: Connection;

beforeEach(() => {
const conn1: Connection | undefined | null = device1.activeConnection();
const conn2: Connection | undefined | null = device2.activeConnection();

if (!conn1 || !conn2) {
throw new Error(`Connections weren't both open at beforeEach`);
}

connection1 = conn1;
connection2 = conn2;
});

it('should set callerInfo to null on origin connection', () => {
assert.equal(connection1!.callerInfo, null);
});

it('should show isVerified on aliceStir connection', () => {
assert.equal(connection2!.callerInfo!.isVerified, true);
});

it('should reject the call', (done) => {
connection1.once('disconnect', () => done());
connection2.reject();
});
});
});
});

function expectEvent(eventName: string, emitter: EventEmitter) {
return new Promise(resolve => {
emitter.once(eventName, () => resolve());
});
}
4 changes: 2 additions & 2 deletions tests/lib/token.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
const Twilio = require('twilio');
const env = require('../env.js');

function generateAccessToken(identity, ttl) {
function generateAccessToken(identity, ttl, appSid) {
const accessToken = new Twilio.jwt.AccessToken(env.accountSid,
env.apiKeySid,
env.apiKeySecret,
{ ttl: ttl || 300, identity });

accessToken.addGrant(new Twilio.jwt.AccessToken.VoiceGrant({
incomingAllow: true,
outgoingApplicationSid: env.appSid,
outgoingApplicationSid: appSid || env.appSid,
}));

return accessToken.toJwt();
Expand Down
135 changes: 135 additions & 0 deletions tests/unit/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,141 @@ describe('Connection', function() {
assert.equal(conn.customParameters.get('baz'), 123);
});

context('when incoming', () => {
it('should populate the .callerInfo fields appropriately when StirStatus is A', () => {
conn = new Connection(config, Object.assign(options, { callParameters: {
StirStatus: 'TN-Validation-Passed-A',
CallSid: 'CA123',
From: '929-321-2323',
}}));
let callerInfo: Connection.CallerInfo;
if (conn.callerInfo !== null) {
callerInfo = conn.callerInfo;
assert.equal(callerInfo.isVerified, true);
} else {
throw Error('callerInfo object null, but expected to be populated');
}
});

it('should populate the .callerInfo fields appropriately when StirStatus is B', () => {
conn = new Connection(config, Object.assign(options, { callParameters: {
StirStatus: 'TN-Validation-Passed-B',
CallSid: 'CA123',
From: '1-929-321-2323',
}}));
let callerInfo: Connection.CallerInfo;
if (conn.callerInfo !== null) {
callerInfo = conn.callerInfo;
assert.equal(callerInfo.isVerified, false);
} else {
throw Error('callerInfo object null, but expected to be populated');
}
});

it('should populate the .callerInfo fields appropriately when StirStatus is C', () => {
conn = new Connection(config, Object.assign(options, { callParameters: {
StirStatus: 'TN-Validation-Passed-C',
CallSid: 'CA123',
From: '1 (929) 321-2323',
}}));
let callerInfo: Connection.CallerInfo;
if (conn.callerInfo !== null) {
callerInfo = conn.callerInfo;
assert.equal(callerInfo.isVerified, false);
} else {
throw Error('callerInfo object null, but expected to be populated');
}
});

it('should populate the .callerInfo fields appropriately when StirStatus is failed-A', () => {
conn = new Connection(config, Object.assign(options, { callParameters: {
StirStatus: 'TN-Validation-Failed-A',
CallSid: 'CA123',
From: '1 (929) 321 2323',
}}));
let callerInfo: Connection.CallerInfo;
if (conn.callerInfo !== null) {
callerInfo = conn.callerInfo;
assert.equal(callerInfo.isVerified, false);
} else {
throw Error('callerInfo object null, but expected to be populated');
}
});

it('should populate the .callerInfo fields appropriately when StirStatus is failed-B', () => {
conn = new Connection(config, Object.assign(options, { callParameters: {
StirStatus: 'TN-Validation-Failed-B',
CallSid: 'CA123',
From: '1 929 321 2323',
}}));
let callerInfo: Connection.CallerInfo;
if (conn.callerInfo !== null) {
callerInfo = conn.callerInfo;
assert.equal(callerInfo.isVerified, false);
} else {
throw Error('callerInfo object null, but expected to be populated');
}
});

it('should populate the .callerInfo fields appropriately when StirStatus is failed-C', () => {
conn = new Connection(config, Object.assign(options, { callParameters: {
StirStatus: 'TN-Validation-Failed-C',
CallSid: 'CA123',
From: '19293212323',
}}));
let callerInfo: Connection.CallerInfo;
if (conn.callerInfo !== null) {
callerInfo = conn.callerInfo;
assert.equal(callerInfo.isVerified, false);
} else {
throw Error('callerInfo object null, but expected to be populated');
}
});

it('should populate the .callerInfo fields appropriately when StirStatus is no-validation', () => {
conn = new Connection(config, Object.assign(options, { callParameters: {
StirStatus: 'TN-No-Validation',
CallSid: 'CA123',
From: '+19293212323',
}}));
let callerInfo: Connection.CallerInfo;
if (conn.callerInfo !== null) {
callerInfo = conn.callerInfo;
assert.equal(callerInfo.isVerified, false);
} else {
throw Error('callerInfo object null, but expected to be populated');
}
});

it('should set .callerInfo.isVerified to false when StirStatus is undefined', () => {
conn = new Connection(config, Object.assign(options, { callParameters: {
CallSid: 'CA123',
From: '19293212323',
}}));
let callerInfo: Connection.CallerInfo;
assert.equal(conn.callerInfo!.isVerified, false);
});

it('should set .callerInfo to null when From is not a number', () => {
conn = new Connection(config, Object.assign(options, { callParameters: {
CallSid: 'CA123',
From: 'client:alice',
StirStatus: 'TN-Validation-Passed-A',
}}));
let callerInfo: Connection.CallerInfo;
assert.equal(conn.callerInfo, null);
});
});

context('when outgoing', () => {
it('should not populate the .callerInfo fields, instead return null', () => {
conn = new Connection(config, Object.assign(options, { callParameters: {
StirStatus: 'TN-Validation-Passed-A',
}}));
assert.equal(conn.callerInfo, null);
});
});

it('should set .direction to CallDirection.Outgoing if there is no CallSid', () => {
const callParameters = { foo: 'bar' };
conn = new Connection(config, Object.assign(options, { callParameters }));
Expand Down

0 comments on commit b4485de

Please sign in to comment.