Refactor api.js into web_api, which encapsulates all web access

This commit is contained in:
Scott Nonnenberg 2018-05-25 18:01:56 -07:00
parent 37ebe9fcec
commit f3bd0cf903
16 changed files with 763 additions and 577 deletions

View file

@ -57,7 +57,6 @@ module.exports = function(grunt) {
'libtextsecure/helpers.js',
'libtextsecure/stringview.js',
'libtextsecure/event_target.js',
'libtextsecure/api.js',
'libtextsecure/account_manager.js',
'libtextsecure/websocket-resources.js',
'libtextsecure/message_receiver.js',

View file

@ -4,8 +4,7 @@
"disableAutoUpdate": false,
"openDevTools": false,
"buildExpiration": 0,
"certificateAuthorities": [
"-----BEGIN CERTIFICATE-----\nMIID7zCCAtegAwIBAgIJAIm6LatK5PNiMA0GCSqGSIb3DQEBBQUAMIGNMQswCQYD\nVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5j\naXNjbzEdMBsGA1UECgwUT3BlbiBXaGlzcGVyIFN5c3RlbXMxHTAbBgNVBAsMFE9w\nZW4gV2hpc3BlciBTeXN0ZW1zMRMwEQYDVQQDDApUZXh0U2VjdXJlMB4XDTEzMDMy\nNTIyMTgzNVoXDTIzMDMyMzIyMTgzNVowgY0xCzAJBgNVBAYTAlVTMRMwEQYDVQQI\nDApDYWxpZm9ybmlhMRYwFAYDVQQHDA1TYW4gRnJhbmNpc2NvMR0wGwYDVQQKDBRP\ncGVuIFdoaXNwZXIgU3lzdGVtczEdMBsGA1UECwwUT3BlbiBXaGlzcGVyIFN5c3Rl\nbXMxEzARBgNVBAMMClRleHRTZWN1cmUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw\nggEKAoIBAQDBSWBpOCBDF0i4q2d4jAXkSXUGpbeWugVPQCjaL6qD9QDOxeW1afvf\nPo863i6Crq1KDxHpB36EwzVcjwLkFTIMeo7t9s1FQolAt3mErV2U0vie6Ves+yj6\ngrSfxwIDAcdsKmI0a1SQCZlr3Q1tcHAkAKFRxYNawADyps5B+Zmqcgf653TXS5/0\nIPPQLocLn8GWLwOYNnYfBvILKDMItmZTtEbucdigxEA9mfIvvHADEbteLtVgwBm9\nR5vVvtwrD6CCxI3pgH7EH7kMP0Od93wLisvn1yhHY7FuYlrkYqdkMvWUrKoASVw4\njb69vaeJCUdU+HCoXOSP1PQcL6WenNCHAgMBAAGjUDBOMB0GA1UdDgQWBBQBixjx\nP/s5GURuhYa+lGUypzI8kDAfBgNVHSMEGDAWgBQBixjxP/s5GURuhYa+lGUypzI8\nkDAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBQUAA4IBAQB+Hr4hC56m0LvJAu1R\nK6NuPDbTMEN7/jMojFHxH4P3XPFfupjR+bkDq0pPOU6JjIxnrD1XD/EVmTTaTVY5\niOheyv7UzJOefb2pLOc9qsuvI4fnaESh9bhzln+LXxtCrRPGhkxA1IMIo3J/s2WF\n/KVYZyciu6b4ubJ91XPAuBNZwImug7/srWvbpk0hq6A6z140WTVSKtJG7EP41kJe\n/oF4usY5J7LPkxK3LWzMJnb5EIJDmRvyH8pyRwWg6Qm6qiGFaI4nL8QU4La1x2en\n4DGXRaLMPRwjELNgQPodR38zoCMuA8gHZfZYYoZ7D7Q1wNUiVHcxuFrEeBaYJbLE\nrwLV\n-----END CERTIFICATE-----\n"
],
"certificateAuthority":
"-----BEGIN CERTIFICATE-----\nMIID7zCCAtegAwIBAgIJAIm6LatK5PNiMA0GCSqGSIb3DQEBBQUAMIGNMQswCQYD\nVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5j\naXNjbzEdMBsGA1UECgwUT3BlbiBXaGlzcGVyIFN5c3RlbXMxHTAbBgNVBAsMFE9w\nZW4gV2hpc3BlciBTeXN0ZW1zMRMwEQYDVQQDDApUZXh0U2VjdXJlMB4XDTEzMDMy\nNTIyMTgzNVoXDTIzMDMyMzIyMTgzNVowgY0xCzAJBgNVBAYTAlVTMRMwEQYDVQQI\nDApDYWxpZm9ybmlhMRYwFAYDVQQHDA1TYW4gRnJhbmNpc2NvMR0wGwYDVQQKDBRP\ncGVuIFdoaXNwZXIgU3lzdGVtczEdMBsGA1UECwwUT3BlbiBXaGlzcGVyIFN5c3Rl\nbXMxEzARBgNVBAMMClRleHRTZWN1cmUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw\nggEKAoIBAQDBSWBpOCBDF0i4q2d4jAXkSXUGpbeWugVPQCjaL6qD9QDOxeW1afvf\nPo863i6Crq1KDxHpB36EwzVcjwLkFTIMeo7t9s1FQolAt3mErV2U0vie6Ves+yj6\ngrSfxwIDAcdsKmI0a1SQCZlr3Q1tcHAkAKFRxYNawADyps5B+Zmqcgf653TXS5/0\nIPPQLocLn8GWLwOYNnYfBvILKDMItmZTtEbucdigxEA9mfIvvHADEbteLtVgwBm9\nR5vVvtwrD6CCxI3pgH7EH7kMP0Od93wLisvn1yhHY7FuYlrkYqdkMvWUrKoASVw4\njb69vaeJCUdU+HCoXOSP1PQcL6WenNCHAgMBAAGjUDBOMB0GA1UdDgQWBBQBixjx\nP/s5GURuhYa+lGUypzI8kDAfBgNVHSMEGDAWgBQBixjxP/s5GURuhYa+lGUypzI8\nkDAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBQUAA4IBAQB+Hr4hC56m0LvJAu1R\nK6NuPDbTMEN7/jMojFHxH4P3XPFfupjR+bkDq0pPOU6JjIxnrD1XD/EVmTTaTVY5\niOheyv7UzJOefb2pLOc9qsuvI4fnaESh9bhzln+LXxtCrRPGhkxA1IMIo3J/s2WF\n/KVYZyciu6b4ubJ91XPAuBNZwImug7/srWvbpk0hq6A6z140WTVSKtJG7EP41kJe\n/oF4usY5J7LPkxK3LWzMJnb5EIJDmRvyH8pyRwWg6Qm6qiGFaI4nL8QU4La1x2en\n4DGXRaLMPRwjELNgQPodR38zoCMuA8gHZfZYYoZ7D7Q1wNUiVHcxuFrEeBaYJbLE\nrwLV\n-----END CERTIFICATE-----\n",
"import": false
}

View file

@ -14,6 +14,10 @@
(async function() {
'use strict';
// We add this to window here because the default Node context is erased at the end
// of preload.js processing
window.setImmediate = window.nodeSetImmediate;
const { IdleDetector, MessageDataMigrator } = Signal.Workflow;
const { Errors, Message } = window.Signal.Types;
const { upgradeMessageSchema } = window.Signal.Migrations;

678
js/modules/web_api.js Normal file
View file

@ -0,0 +1,678 @@
const WebSocket = require('websocket').w3cwebsocket;
const fetch = require('node-fetch');
const ProxyAgent = require('proxy-agent');
const is = require('@sindresorhus/is');
/* global Buffer: false */
/* global setTimeout: false */
/* eslint-disable more/no-then, no-bitwise, no-nested-ternary */
function _btoa(str) {
let buffer;
if (str instanceof Buffer) {
buffer = str;
} else {
buffer = Buffer.from(str.toString(), 'binary');
}
return buffer.toString('base64');
}
const _call = object => Object.prototype.toString.call(object);
const ArrayBufferToString = _call(new ArrayBuffer());
const Uint8ArrayToString = _call(new Uint8Array());
function _getString(thing) {
if (typeof thing !== 'string') {
if (_call(thing) === Uint8ArrayToString)
return String.fromCharCode.apply(null, thing);
if (_call(thing) === ArrayBufferToString)
return _getString(new Uint8Array(thing));
}
return thing;
}
function _b64ToUint6(nChr) {
return nChr > 64 && nChr < 91
? nChr - 65
: nChr > 96 && nChr < 123
? nChr - 71
: nChr > 47 && nChr < 58
? nChr + 4
: nChr === 43
? 62
: nChr === 47
? 63
: 0;
}
function _getStringable(thing) {
return (
typeof thing === 'string' ||
typeof thing === 'number' ||
typeof thing === 'boolean' ||
(thing === Object(thing) &&
(_call(thing) === ArrayBufferToString ||
_call(thing) === Uint8ArrayToString))
);
}
function _ensureStringed(thing) {
if (_getStringable(thing)) {
return _getString(thing);
} else if (thing instanceof Array) {
const res = [];
for (let i = 0; i < thing.length; i += 1) {
res[i] = _ensureStringed(thing[i]);
}
return res;
} else if (thing === Object(thing)) {
const res = {};
// eslint-disable-next-line guard-for-in, no-restricted-syntax
for (const key in thing) {
res[key] = _ensureStringed(thing[key]);
}
return res;
} else if (thing === null) {
return null;
}
throw new Error(`unsure of how to jsonify object of type ${typeof thing}`);
}
function _jsonThing(thing) {
return JSON.stringify(_ensureStringed(thing));
}
function _base64ToBytes(sBase64, nBlocksSize) {
const sB64Enc = sBase64.replace(/[^A-Za-z0-9+/]/g, '');
const nInLen = sB64Enc.length;
const nOutLen = nBlocksSize
? Math.ceil(((nInLen * 3 + 1) >> 2) / nBlocksSize) * nBlocksSize
: (nInLen * 3 + 1) >> 2;
const aBBytes = new ArrayBuffer(nOutLen);
const taBytes = new Uint8Array(aBBytes);
for (
let nMod3, nMod4, nUint24 = 0, nOutIdx = 0, nInIdx = 0;
nInIdx < nInLen;
nInIdx += 1
) {
nMod4 = nInIdx & 3;
nUint24 |= _b64ToUint6(sB64Enc.charCodeAt(nInIdx)) << (18 - 6 * nMod4);
if (nMod4 === 3 || nInLen - nInIdx === 1) {
for (
nMod3 = 0;
nMod3 < 3 && nOutIdx < nOutLen;
nMod3 += 1, nOutIdx += 1
) {
taBytes[nOutIdx] = (nUint24 >>> ((16 >>> nMod3) & 24)) & 255;
}
nUint24 = 0;
}
}
return aBBytes;
}
function _validateResponse(response, schema) {
try {
// eslint-disable-next-line guard-for-in, no-restricted-syntax
for (const i in schema) {
switch (schema[i]) {
case 'object':
case 'string':
case 'number':
// eslint-disable-next-line valid-typeof
if (typeof response[i] !== schema[i]) {
return false;
}
break;
default:
}
}
} catch (ex) {
return false;
}
return true;
}
function _createSocket(url, { certificateAuthority, proxyUrl }) {
let requestOptions;
if (proxyUrl) {
requestOptions = {
ca: certificateAuthority,
agent: new ProxyAgent(proxyUrl),
};
} else {
requestOptions = {
ca: certificateAuthority,
};
}
// eslint-disable-next-line new-cap
return new WebSocket(url, null, null, null, requestOptions);
}
function _promiseAjax(providedUrl, options) {
return new Promise((resolve, reject) => {
const url = providedUrl || `${options.host}/${options.path}`;
console.log(options.type, url);
const timeout =
typeof options.timeout !== 'undefined' ? options.timeout : 10000;
const { proxyUrl } = options;
let agent;
if (proxyUrl) {
agent = new ProxyAgent(proxyUrl);
}
const fetchOptions = {
method: options.type,
body: options.data || null,
headers: { 'X-Signal-Agent': 'OWD' },
agent,
ca: options.certificateAuthority,
timeout,
};
if (fetchOptions.body instanceof ArrayBuffer) {
// node-fetch doesn't support ArrayBuffer, only node Buffer
const contentLength = fetchOptions.body.byteLength;
fetchOptions.body = Buffer.from(fetchOptions.body);
// node-fetch doesn't set content-length like S3 requires
fetchOptions.headers['Content-Length'] = contentLength;
}
if (options.user && options.password) {
const user = _getString(options.user);
const password = _getString(options.password);
const auth = _btoa(`${user}:${password}`);
fetchOptions.headers.Authorization = `Basic ${auth}`;
}
if (options.contentType) {
fetchOptions.headers['Content-Type'] = options.contentType;
}
fetch(url, fetchOptions)
.then(response => {
let resultPromise;
if (
options.responseType === 'json' &&
response.headers.get('Content-Type') === 'application/json'
) {
resultPromise = response.json();
} else if (options.responseType === 'arraybuffer') {
resultPromise = response.buffer();
} else {
resultPromise = response.text();
}
return resultPromise.then(result => {
if (options.responseType === 'arraybuffer') {
// eslint-disable-next-line no-param-reassign
result = result.buffer.slice(
result.byteOffset,
result.byteOffset + result.byteLength
);
}
if (options.responseType === 'json') {
if (options.validateResponse) {
if (!_validateResponse(result, options.validateResponse)) {
console.log(options.type, url, response.status, 'Error');
reject(
HTTPError(
'promiseAjax: invalid response',
response.status,
result,
options.stack
)
);
}
}
}
if (response.status >= 0 && response.status < 400) {
console.log(options.type, url, response.status, 'Success');
resolve(result, response.status);
} else {
console.log(options.type, url, response.status, 'Error');
reject(
HTTPError(
'promiseAjax: error response',
response.status,
result,
options.stack
)
);
}
});
})
.catch(e => {
console.log(options.type, url, 0, 'Error');
const stack = `${e.stack}\nInitial stack:\n${options.stack}`;
reject(HTTPError('promiseAjax catch', 0, e.toString(), stack));
});
});
}
function _retryAjax(url, options, providedLimit, providedCount) {
const count = (providedCount || 0) + 1;
const limit = providedLimit || 3;
return _promiseAjax(url, options).catch(e => {
if (e.name === 'HTTPError' && e.code === -1 && count < limit) {
return new Promise(resolve => {
setTimeout(() => {
resolve(_retryAjax(url, options, limit, count));
}, 1000);
});
}
throw e;
});
}
function _outerAjax(url, options) {
// eslint-disable-next-line no-param-reassign
options.stack = new Error().stack; // just in case, save stack here.
return _retryAjax(url, options);
}
function HTTPError(message, providedCode, response, stack) {
const code = providedCode > 999 || providedCode < 100 ? -1 : providedCode;
const e = new Error(`${message}; code: ${code}`);
e.name = 'HTTPError';
e.code = code;
e.stack += `\nOriginal stack:\n${stack}`;
if (response) {
e.response = response;
}
return e;
}
const URL_CALLS = {
accounts: 'v1/accounts',
devices: 'v1/devices',
keys: 'v2/keys',
signed: 'v2/keys/signed',
messages: 'v1/messages',
attachment: 'v1/attachments',
profile: 'v1/profile',
};
module.exports = {
initialize,
};
// We first set up the data that won't change during this session of the app
function initialize({ url, cdnUrl, certificateAuthority, proxyUrl }) {
if (!is.string(url)) {
throw new Error('WebAPI.initialize: Invalid server url');
}
if (!is.string(cdnUrl)) {
throw new Error('WebAPI.initialize: Invalid cdnUrl');
}
if (!is.string(certificateAuthority)) {
throw new Error('WebAPI.initialize: Invalid certificateAuthority');
}
// Thanks to function-hoisting, we can put this return statement before all of the
// below function definitions.
return {
connect,
};
// Then we connect to the server with user-specific information. This is the only API
// exposed to the browser context, ensuring that it can't connect to arbitrary
// locations.
function connect({ username: initialUsername, password: initialPassword }) {
let username = initialUsername;
let password = initialPassword;
// Thanks, function hoisting!
return {
confirmCode,
getAttachment,
getAvatar,
getDevices,
getKeysForNumber,
getMessageSocket,
getMyKeys,
getProfile,
getProvisioningSocket,
putAttachment,
registerKeys,
requestVerificationSMS,
requestVerificationVoice,
sendMessages,
setSignedPreKey,
};
function _ajax(param) {
if (!param.urlParameters) {
// eslint-disable-next-line no-param-reassign
param.urlParameters = '';
}
return _outerAjax(null, {
certificateAuthority,
contentType: 'application/json; charset=utf-8',
data: param.jsonData && _jsonThing(param.jsonData),
host: url,
password,
path: URL_CALLS[param.call] + param.urlParameters,
proxyUrl,
responseType: param.responseType,
timeout: param.timeout,
type: param.httpType,
user: username,
validateResponse: param.validateResponse,
}).catch(e => {
const { code } = e;
if (code === 200) {
// happens sometimes when we get no response
// (TODO: Fix server to return 204? instead)
return null;
}
let message;
switch (code) {
case -1:
message =
'Failed to connect to the server, please check your network connection.';
break;
case 413:
message = 'Rate limit exceeded, please try again later.';
break;
case 403:
message = 'Invalid code, please try again.';
break;
case 417:
// TODO: This shouldn't be a thing?, but its in the API doc?
message = 'Number already registered.';
break;
case 401:
message =
'Invalid authentication, most likely someone re-registered and invalidated our registration.';
break;
case 404:
message = 'Number is not registered.';
break;
default:
message =
'The server rejected our query, please file a bug report.';
}
e.message = message;
throw e;
});
}
function getProfile(number) {
return _ajax({
call: 'profile',
httpType: 'GET',
urlParameters: `/${number}`,
responseType: 'json',
});
}
function getAvatar(path) {
// Using _outerAJAX, since it's not hardcoded to the Signal Server. Unlike our
// attachment CDN, it uses our self-signed certificate, so we pass it in.
return _outerAjax(`${cdnUrl}/${path}`, {
certificateAuthority,
contentType: 'application/octet-stream',
proxyUrl,
responseType: 'arraybuffer',
timeout: 0,
type: 'GET',
});
}
function requestVerificationSMS(number) {
return _ajax({
call: 'accounts',
httpType: 'GET',
urlParameters: `/sms/code/${number}`,
});
}
function requestVerificationVoice(number) {
return _ajax({
call: 'accounts',
httpType: 'GET',
urlParameters: `/voice/code/${number}`,
});
}
async function confirmCode(
number,
code,
newPassword,
signalingKey,
registrationId,
deviceName
) {
const jsonData = {
signalingKey: _btoa(_getString(signalingKey)),
supportsSms: false,
fetchesMessages: true,
registrationId,
};
let call;
let urlPrefix;
let schema;
let responseType;
if (deviceName) {
jsonData.name = deviceName;
call = 'devices';
urlPrefix = '/';
schema = { deviceId: 'number' };
responseType = 'json';
} else {
call = 'accounts';
urlPrefix = '/code/';
}
// We update our saved username and password, since we're creating a new account
username = number;
password = newPassword;
const response = await _ajax({
call,
httpType: 'PUT',
urlParameters: urlPrefix + code,
jsonData,
responseType,
validateResponse: schema,
});
// From here on out, our username will be our phone number combined with device
username = `${number}.${response.deviceId || 1}`;
return response;
}
function getDevices() {
return _ajax({
call: 'devices',
httpType: 'GET',
});
}
function registerKeys(genKeys) {
const keys = {};
keys.identityKey = _btoa(_getString(genKeys.identityKey));
keys.signedPreKey = {
keyId: genKeys.signedPreKey.keyId,
publicKey: _btoa(_getString(genKeys.signedPreKey.publicKey)),
signature: _btoa(_getString(genKeys.signedPreKey.signature)),
};
keys.preKeys = [];
let j = 0;
// eslint-disable-next-line guard-for-in, no-restricted-syntax
for (const i in genKeys.preKeys) {
keys.preKeys[j] = {
keyId: genKeys.preKeys[i].keyId,
publicKey: _btoa(_getString(genKeys.preKeys[i].publicKey)),
};
j += 1;
}
// This is just to make the server happy
// (v2 clients should choke on publicKey)
keys.lastResortKey = { keyId: 0x7fffffff, publicKey: _btoa('42') };
return _ajax({
call: 'keys',
httpType: 'PUT',
jsonData: keys,
});
}
function setSignedPreKey(signedPreKey) {
return _ajax({
call: 'signed',
httpType: 'PUT',
jsonData: {
keyId: signedPreKey.keyId,
publicKey: _btoa(_getString(signedPreKey.publicKey)),
signature: _btoa(_getString(signedPreKey.signature)),
},
});
}
function getMyKeys() {
return _ajax({
call: 'keys',
httpType: 'GET',
responseType: 'json',
validateResponse: { count: 'number' },
}).then(res => res.count);
}
function getKeysForNumber(number, deviceId = '*') {
return _ajax({
call: 'keys',
httpType: 'GET',
urlParameters: `/${number}/${deviceId}`,
responseType: 'json',
validateResponse: { identityKey: 'string', devices: 'object' },
}).then(res => {
if (res.devices.constructor !== Array) {
throw new Error('Invalid response');
}
res.identityKey = _base64ToBytes(res.identityKey);
res.devices.forEach(device => {
if (
!_validateResponse(device, { signedPreKey: 'object' }) ||
!_validateResponse(device.signedPreKey, {
publicKey: 'string',
signature: 'string',
})
) {
throw new Error('Invalid signedPreKey');
}
if (device.preKey) {
if (
!_validateResponse(device, { preKey: 'object' }) ||
!_validateResponse(device.preKey, { publicKey: 'string' })
) {
throw new Error('Invalid preKey');
}
// eslint-disable-next-line no-param-reassign
device.preKey.publicKey = _base64ToBytes(device.preKey.publicKey);
}
// eslint-disable-next-line no-param-reassign
device.signedPreKey.publicKey = _base64ToBytes(
device.signedPreKey.publicKey
);
// eslint-disable-next-line no-param-reassign
device.signedPreKey.signature = _base64ToBytes(
device.signedPreKey.signature
);
});
return res;
});
}
function sendMessages(destination, messageArray, timestamp, silent) {
const jsonData = { messages: messageArray, timestamp };
if (silent) {
jsonData.silent = true;
}
return _ajax({
call: 'messages',
httpType: 'PUT',
urlParameters: `/${destination}`,
jsonData,
responseType: 'json',
});
}
function getAttachment(id) {
return _ajax({
call: 'attachment',
httpType: 'GET',
urlParameters: `/${id}`,
responseType: 'json',
validateResponse: { location: 'string' },
}).then(response =>
// Using _outerAJAX, since it's not hardcoded to the Signal Server
_outerAjax(response.location, {
contentType: 'application/octet-stream',
proxyUrl,
responseType: 'arraybuffer',
timeout: 0,
type: 'GET',
})
);
}
function putAttachment(encryptedBin) {
return _ajax({
call: 'attachment',
httpType: 'GET',
responseType: 'json',
}).then(response =>
// Using _outerAJAX, since it's not hardcoded to the Signal Server
_outerAjax(response.location, {
contentType: 'application/octet-stream',
data: encryptedBin,
processData: false,
proxyUrl,
timeout: 0,
type: 'PUT',
}).then(() => response.idString)
);
}
function getMessageSocket() {
console.log('opening message socket', url);
const fixedScheme = url
.replace('https://', 'wss://')
.replace('http://', 'ws://');
const login = encodeURIComponent(username);
const pass = encodeURIComponent(password);
return _createSocket(
`${fixedScheme}/v1/websocket/?login=${login}&password=${pass}&agent=OWD`,
{ certificateAuthority, proxyUrl }
);
}
function getProvisioningSocket() {
console.log('opening provisioning socket', url);
const fixedScheme = url
.replace('https://', 'wss://')
.replace('http://', 'ws://');
return _createSocket(
`${fixedScheme}/v1/websocket/provisioning/?agent=OWD`,
{ certificateAuthority, proxyUrl }
);
}
}
}

View file

@ -5,7 +5,7 @@
var ARCHIVE_AGE = 7 * 24 * 60 * 60 * 1000;
function AccountManager(url, username, password) {
this.server = new TextSecureServer(url, username, password);
this.server = window.WebAPI.connect({ username, password });
this.pending = Promise.resolve();
}
@ -426,7 +426,6 @@
'regionCode',
libphonenumber.util.getRegionCodeForNumber(number)
);
this.server.username = textsecure.storage.get('number_id');
}.bind(this)
);
},

View file

@ -1,517 +0,0 @@
/* global nodeBuffer: false */
/* global nodeWebSocket: false */
/* global nodeFetch: false */
/* global nodeSetImmediate: false */
/* global ProxyAgent: false */
/* global window: false */
/* global getString: false */
/* global btoa: false */
/* global StringView: false */
/* global textsecure: false */
/* eslint-disable more/no-then */
// eslint-disable-next-line no-unused-vars, func-names
const TextSecureServer = (function() {
function validateResponse(response, schema) {
try {
// eslint-disable-next-line guard-for-in, no-restricted-syntax
for (const i in schema) {
switch (schema[i]) {
case 'object':
case 'string':
case 'number':
// eslint-disable-next-line valid-typeof
if (typeof response[i] !== schema[i]) {
return false;
}
break;
default:
}
}
} catch (ex) {
return false;
}
return true;
}
function createSocket(url) {
const { proxyUrl } = window.config;
let requestOptions;
if (proxyUrl) {
requestOptions = {
ca: window.config.certificateAuthorities,
agent: new ProxyAgent(proxyUrl),
};
} else {
requestOptions = {
ca: window.config.certificateAuthorities,
};
}
// eslint-disable-next-line new-cap
return new nodeWebSocket(url, null, null, null, requestOptions);
}
// We add this to window here because the default Node context is erased at the end
// of preload.js processing
window.setImmediate = nodeSetImmediate;
function promiseAjax(providedUrl, options) {
return new Promise((resolve, reject) => {
const url = providedUrl || `${options.host}/${options.path}`;
console.log(options.type, url);
const timeout =
typeof options.timeout !== 'undefined' ? options.timeout : 10000;
const { proxyUrl } = window.config;
let agent;
if (proxyUrl) {
agent = new ProxyAgent(proxyUrl);
}
const fetchOptions = {
method: options.type,
body: options.data || null,
headers: { 'X-Signal-Agent': 'OWD' },
agent,
ca: options.certificateAuthorities,
timeout,
};
if (fetchOptions.body instanceof ArrayBuffer) {
// node-fetch doesn't support ArrayBuffer, only node Buffer
const contentLength = fetchOptions.body.byteLength;
fetchOptions.body = nodeBuffer.from(fetchOptions.body);
// node-fetch doesn't set content-length like S3 requires
fetchOptions.headers['Content-Length'] = contentLength;
}
if (options.user && options.password) {
const user = getString(options.user);
const password = getString(options.password);
const auth = btoa(`${user}:${password}`);
fetchOptions.headers.Authorization = `Basic ${auth}`;
}
if (options.contentType) {
fetchOptions.headers['Content-Type'] = options.contentType;
}
nodeFetch(url, fetchOptions)
.then(response => {
let resultPromise;
if (
options.responseType === 'json' &&
response.headers.get('Content-Type') === 'application/json'
) {
resultPromise = response.json();
} else if (options.responseType === 'arraybuffer') {
resultPromise = response.buffer();
} else {
resultPromise = response.text();
}
return resultPromise.then(result => {
if (options.responseType === 'arraybuffer') {
// eslint-disable-next-line no-param-reassign
result = result.buffer.slice(
result.byteOffset,
result.byteOffset + result.byteLength
);
}
if (options.responseType === 'json') {
if (options.validateResponse) {
if (!validateResponse(result, options.validateResponse)) {
console.log(options.type, url, response.status, 'Error');
reject(
HTTPError(
'promiseAjax: invalid response',
response.status,
result,
options.stack
)
);
}
}
}
if (response.status >= 0 && response.status < 400) {
console.log(options.type, url, response.status, 'Success');
resolve(result, response.status);
} else {
console.log(options.type, url, response.status, 'Error');
reject(
HTTPError(
'promiseAjax: error response',
response.status,
result,
options.stack
)
);
}
});
})
.catch(e => {
console.log(options.type, url, 0, 'Error');
const stack = `${e.stack}\nInitial stack:\n${options.stack}`;
reject(HTTPError('promiseAjax catch', 0, e.toString(), stack));
});
});
}
function retryAjax(url, options, providedLimit, providedCount) {
const count = (providedCount || 0) + 1;
const limit = providedLimit || 3;
return promiseAjax(url, options).catch(e => {
if (e.name === 'HTTPError' && e.code === -1 && count < limit) {
return new Promise(resolve => {
setTimeout(() => {
resolve(retryAjax(url, options, limit, count));
}, 1000);
});
}
throw e;
});
}
function ajax(url, options) {
// eslint-disable-next-line no-param-reassign
options.stack = new Error().stack; // just in case, save stack here.
return retryAjax(url, options);
}
function HTTPError(message, providedCode, response, stack) {
const code = providedCode > 999 || providedCode < 100 ? -1 : providedCode;
const e = new Error(`${message}; code: ${code}`);
e.name = 'HTTPError';
e.code = code;
e.stack += `\nOriginal stack:\n${stack}`;
if (response) {
e.response = response;
}
return e;
}
const URL_CALLS = {
accounts: 'v1/accounts',
devices: 'v1/devices',
keys: 'v2/keys',
signed: 'v2/keys/signed',
messages: 'v1/messages',
attachment: 'v1/attachments',
profile: 'v1/profile',
};
// eslint-disable-next-line no-shadow
function TextSecureServer(url, username, password, cdnUrl) {
if (typeof url !== 'string') {
throw new Error('Invalid server url');
}
this.url = url;
this.cdnUrl = cdnUrl;
this.username = username;
this.password = password;
}
TextSecureServer.prototype = {
constructor: TextSecureServer,
ajax(param) {
if (!param.urlParameters) {
// eslint-disable-next-line no-param-reassign
param.urlParameters = '';
}
return ajax(null, {
host: this.url,
path: URL_CALLS[param.call] + param.urlParameters,
type: param.httpType,
data: param.jsonData && textsecure.utils.jsonThing(param.jsonData),
contentType: 'application/json; charset=utf-8',
responseType: param.responseType,
user: this.username,
password: this.password,
validateResponse: param.validateResponse,
certificateAuthorities: window.config.certificateAuthorities,
timeout: param.timeout,
}).catch(e => {
const { code } = e;
if (code === 200) {
// happens sometimes when we get no response
// (TODO: Fix server to return 204? instead)
return null;
}
let message;
switch (code) {
case -1:
message =
'Failed to connect to the server, please check your network connection.';
break;
case 413:
message = 'Rate limit exceeded, please try again later.';
break;
case 403:
message = 'Invalid code, please try again.';
break;
case 417:
// TODO: This shouldn't be a thing?, but its in the API doc?
message = 'Number already registered.';
break;
case 401:
message =
'Invalid authentication, most likely someone re-registered and invalidated our registration.';
break;
case 404:
message = 'Number is not registered.';
break;
default:
message =
'The server rejected our query, please file a bug report.';
}
e.message = message;
throw e;
});
},
getProfile(number) {
return this.ajax({
call: 'profile',
httpType: 'GET',
urlParameters: `/${number}`,
responseType: 'json',
});
},
getAvatar(path) {
return ajax(`${this.cdnUrl}/${path}`, {
type: 'GET',
responseType: 'arraybuffer',
contentType: 'application/octet-stream',
certificateAuthorities: window.config.certificateAuthorities,
timeout: 0,
});
},
requestVerificationSMS(number) {
return this.ajax({
call: 'accounts',
httpType: 'GET',
urlParameters: `/sms/code/${number}`,
});
},
requestVerificationVoice(number) {
return this.ajax({
call: 'accounts',
httpType: 'GET',
urlParameters: `/voice/code/${number}`,
});
},
confirmCode(
number,
code,
password,
signalingKey,
registrationId,
deviceName
) {
const jsonData = {
signalingKey: btoa(getString(signalingKey)),
supportsSms: false,
fetchesMessages: true,
registrationId,
};
let call;
let urlPrefix;
let schema;
let responseType;
if (deviceName) {
jsonData.name = deviceName;
call = 'devices';
urlPrefix = '/';
schema = { deviceId: 'number' };
responseType = 'json';
} else {
call = 'accounts';
urlPrefix = '/code/';
}
this.username = number;
this.password = password;
return this.ajax({
call,
httpType: 'PUT',
urlParameters: urlPrefix + code,
jsonData,
responseType,
validateResponse: schema,
});
},
getDevices() {
return this.ajax({
call: 'devices',
httpType: 'GET',
});
},
registerKeys(genKeys) {
const keys = {};
keys.identityKey = btoa(getString(genKeys.identityKey));
keys.signedPreKey = {
keyId: genKeys.signedPreKey.keyId,
publicKey: btoa(getString(genKeys.signedPreKey.publicKey)),
signature: btoa(getString(genKeys.signedPreKey.signature)),
};
keys.preKeys = [];
let j = 0;
// eslint-disable-next-line guard-for-in, no-restricted-syntax
for (const i in genKeys.preKeys) {
keys.preKeys[j] = {
keyId: genKeys.preKeys[i].keyId,
publicKey: btoa(getString(genKeys.preKeys[i].publicKey)),
};
j += 1;
}
// This is just to make the server happy
// (v2 clients should choke on publicKey)
keys.lastResortKey = { keyId: 0x7fffffff, publicKey: btoa('42') };
return this.ajax({
call: 'keys',
httpType: 'PUT',
jsonData: keys,
});
},
setSignedPreKey(signedPreKey) {
return this.ajax({
call: 'signed',
httpType: 'PUT',
jsonData: {
keyId: signedPreKey.keyId,
publicKey: btoa(getString(signedPreKey.publicKey)),
signature: btoa(getString(signedPreKey.signature)),
},
});
},
getMyKeys() {
return this.ajax({
call: 'keys',
httpType: 'GET',
responseType: 'json',
validateResponse: { count: 'number' },
}).then(res => res.count);
},
getKeysForNumber(number, deviceId = '*') {
return this.ajax({
call: 'keys',
httpType: 'GET',
urlParameters: `/${number}/${deviceId}`,
responseType: 'json',
validateResponse: { identityKey: 'string', devices: 'object' },
}).then(res => {
if (res.devices.constructor !== Array) {
throw new Error('Invalid response');
}
res.identityKey = StringView.base64ToBytes(res.identityKey);
res.devices.forEach(device => {
if (
!validateResponse(device, { signedPreKey: 'object' }) ||
!validateResponse(device.signedPreKey, {
publicKey: 'string',
signature: 'string',
})
) {
throw new Error('Invalid signedPreKey');
}
if (device.preKey) {
if (
!validateResponse(device, { preKey: 'object' }) ||
!validateResponse(device.preKey, { publicKey: 'string' })
) {
throw new Error('Invalid preKey');
}
// eslint-disable-next-line no-param-reassign
device.preKey.publicKey = StringView.base64ToBytes(
device.preKey.publicKey
);
}
// eslint-disable-next-line no-param-reassign
device.signedPreKey.publicKey = StringView.base64ToBytes(
device.signedPreKey.publicKey
);
// eslint-disable-next-line no-param-reassign
device.signedPreKey.signature = StringView.base64ToBytes(
device.signedPreKey.signature
);
});
return res;
});
},
sendMessages(destination, messageArray, timestamp, silent) {
const jsonData = { messages: messageArray, timestamp };
if (silent) {
jsonData.silent = true;
}
return this.ajax({
call: 'messages',
httpType: 'PUT',
urlParameters: `/${destination}`,
jsonData,
responseType: 'json',
});
},
getAttachment(id) {
return this.ajax({
call: 'attachment',
httpType: 'GET',
urlParameters: `/${id}`,
responseType: 'json',
validateResponse: { location: 'string' },
}).then(response =>
ajax(response.location, {
timeout: 0,
type: 'GET',
responseType: 'arraybuffer',
contentType: 'application/octet-stream',
})
);
},
putAttachment(encryptedBin) {
return this.ajax({
call: 'attachment',
httpType: 'GET',
responseType: 'json',
}).then(response =>
ajax(response.location, {
timeout: 0,
type: 'PUT',
contentType: 'application/octet-stream',
data: encryptedBin,
processData: false,
}).then(() => response.idString)
);
},
getMessageSocket() {
console.log('opening message socket', this.url);
const fixedScheme = this.url
.replace('https://', 'wss://')
.replace('http://', 'ws://');
const login = encodeURIComponent(this.username);
const password = encodeURIComponent(this.password);
return createSocket(
`${fixedScheme}/v1/websocket/?login=${login}&password=${password}&agent=OWD`
);
},
getProvisioningSocket() {
console.log('opening provisioning socket', this.url);
const fixedScheme = this.url
.replace('https://', 'wss://')
.replace('http://', 'ws://');
return createSocket(
`${fixedScheme}/v1/websocket/provisioning/?agent=OWD`
);
},
};
return TextSecureServer;
})();

View file

@ -1,6 +1,6 @@
/* global window: false */
/* global textsecure: false */
/* global TextSecureServer: false */
/* global WebAPI: false */
/* global libsignal: false */
/* global WebSocketResource: false */
/* global WebSocket: false */
@ -19,7 +19,7 @@ function MessageReceiver(url, username, password, signalingKey, options = {}) {
this.signalingKey = signalingKey;
this.username = username;
this.password = password;
this.server = new TextSecureServer(url, username, password);
this.server = WebAPI.connect({ username, password });
const address = libsignal.SignalProtocolAddress.fromString(username);
this.number = address.getName();

View file

@ -140,7 +140,7 @@ Message.prototype = {
};
function MessageSender(url, username, password, cdn_url) {
this.server = new TextSecureServer(url, username, password, cdn_url);
this.server = WebAPI.connect({ username, password });
this.pendingMessages = {};
}

View file

@ -2,15 +2,6 @@
describe('AccountManager', function() {
let accountManager;
let originalServer;
before(function() {
originalServer = window.TextSecureServer;
window.TextSecureServer = function() {};
});
after(function() {
window.TextSecureServer = originalServer;
});
beforeEach(function() {
accountManager = new window.textsecure.AccountManager();

View file

@ -1,29 +0,0 @@
var getKeysForNumberMap = {};
TextSecureServer.getKeysForNumber = function(number, deviceId) {
var res = getKeysForNumberMap[number];
if (res !== undefined) {
delete getKeysForNumberMap[number];
return Promise.resolve(res);
} else throw new Error('getKeysForNumber of unknown/used number');
};
var messagesSentMap = {};
TextSecureServer.sendMessages = function(destination, messageArray) {
for (i in messageArray) {
var msg = messageArray[i];
if (
(msg.type != 1 && msg.type != 3) ||
msg.destinationDeviceId === undefined ||
msg.destinationRegistrationId === undefined ||
msg.body === undefined ||
msg.timestamp == undefined ||
msg.relay !== undefined ||
msg.destination !== undefined
)
throw new Error('Invalid message');
messagesSentMap[
destination + '.' + messageArray[i].destinationDeviceId
] = msg;
}
};

View file

@ -0,0 +1,56 @@
window.setImmediate = window.nodeSetImmediate;
const getKeysForNumberMap = {};
const messagesSentMap = {};
const fakeCall = () => Promise.resolve();
const fakeAPI = {
confirmCode: fakeCall,
getAttachment: fakeCall,
getAvatar: fakeCall,
getDevices: fakeCall,
// getKeysForNumber: fakeCall,
getMessageSocket: fakeCall,
getMyKeys: fakeCall,
getProfile: fakeCall,
getProvisioningSocket: fakeCall,
putAttachment: fakeCall,
registerKeys: fakeCall,
requestVerificationSMS: fakeCall,
requestVerificationVoice: fakeCall,
// sendMessages: fakeCall,
setSignedPreKey: fakeCall,
getKeysForNumber: function(number, deviceId) {
var res = getKeysForNumberMap[number];
if (res !== undefined) {
delete getKeysForNumberMap[number];
return Promise.resolve(res);
} else throw new Error('getKeysForNumber of unknown/used number');
},
sendMessages: function(destination, messageArray) {
for (i in messageArray) {
var msg = messageArray[i];
if (
(msg.type != 1 && msg.type != 3) ||
msg.destinationDeviceId === undefined ||
msg.destinationRegistrationId === undefined ||
msg.body === undefined ||
msg.timestamp == undefined ||
msg.relay !== undefined ||
msg.destination !== undefined
)
throw new Error('Invalid message');
messagesSentMap[
destination + '.' + messageArray[i].destinationDeviceId
] = msg;
}
},
};
window.WebAPI = {
connect: () => fakeAPI,
};

View file

@ -12,6 +12,8 @@
<div id="tests">
</div>
<script type="text/javascript" src="fake_web_api.js"></script>
<script type="text/javascript" src="test.js"></script>
<script type="text/javascript" src="in_memory_signal_protocol_store.js"></script>
@ -33,8 +35,6 @@
<script type="text/javascript" src="../contacts_parser.js" data-cover></script>
<script type="text/javascript" src="../task_with_timeout.js" data-cover></script>
<script type="text/javascript" src="fake_api.js"></script>
<script type="text/javascript" src="errors_test.js"></script>
<script type="text/javascript" src="helpers_test.js"></script>
<script type="text/javascript" src="storage_test.js"></script>
@ -47,11 +47,12 @@
<script type="text/javascript" src="account_manager_test.js"></script>
<!-- Comment out to turn off code coverage. Useful for getting real callstacks. -->
<script type="text/javascript" src="blanket_mocha.js"></script>
<!-- NOTE: blanket doesn't support modern syntax and will choke until we find a replacement. :0( -->
<!-- <script type="text/javascript" src="blanket_mocha.js"></script> -->
<!-- Uncomment to start tests without code coverage enabled -->
<!-- <script type="text/javascript">
<script type="text/javascript">
mocha.run();
</script> -->
</script>
</body>
</html>

View file

@ -33,6 +33,4 @@ describe('Protocol', function() {
.catch(done);
});
});
// TODO: Use fake_api's hiding of api.sendMessage to test sendmessage.js' maze
});

View file

@ -123,7 +123,7 @@ function prepareURL(pathSegments) {
buildExpiration: config.get('buildExpiration'),
serverUrl: config.get('serverUrl'),
cdnUrl: config.get('cdnUrl'),
certificateAuthorities: config.get('certificateAuthorities'),
certificateAuthority: config.get('certificateAuthority'),
environment: config.environment,
node_version: process.versions.node,
hostname: os.hostname(),

View file

@ -82,7 +82,15 @@ if (window.config.proxyUrl) {
}
window.nodeSetImmediate = setImmediate;
window.nodeWebSocket = require('websocket').w3cwebsocket;
const { initialize: initializeWebAPI } = require('./js/modules/web_api');
window.WebAPI = initializeWebAPI({
url: window.config.serverUrl,
cdnUrl: window.config.cdnUrl,
certificateAuthority: window.config.certificateAuthority,
proxyUrl: window.config.proxyUrl,
});
// Linux seems to periodically let the event loop stop, so this is a global workaround
setInterval(() => {
@ -100,10 +108,6 @@ window.libphonenumber = require('google-libphonenumber').PhoneNumberUtil.getInst
window.libphonenumber.PhoneNumberFormat = require('google-libphonenumber').PhoneNumberFormat;
window.loadImage = require('blueimp-load-image');
window.nodeBuffer = Buffer;
window.nodeFetch = require('node-fetch');
window.ProxyAgent = require('proxy-agent');
// Note: when modifying this file, consider whether our React Components or Backbone Views
// will need these things to render in the Style Guide. If so, go update one of these
// two locations:

View file

@ -564,6 +564,8 @@
</div>
</script>
<script type="text/javascript" src="../libtextsecure/test/fake_web_api.js"></script>
<script type="text/javascript" src="../js/components.js"></script>
<script type="text/javascript" src="../js/reliable_trigger.js" data-cover></script>
<script type="text/javascript" src="test.js"></script>
@ -653,6 +655,7 @@
<script type="text/javascript" src="fixtures_test.js"></script>
<!-- Comment out to turn off code coverage. Useful for getting real callstacks. -->
<!-- NOTE: blanket doesn't support modern syntax and will choke until we find a replacement. :0( -->
<!-- <script type="text/javascript" src="blanket_mocha.js"></script> -->
<!-- Uncomment to start tests without code coverage enabled -->