From f3bd0cf903d0d4e2b8a60544efc6acc23befe245 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Fri, 25 May 2018 18:01:56 -0700 Subject: [PATCH] Refactor api.js into web_api, which encapsulates all web access --- Gruntfile.js | 1 - config/default.json | 5 +- js/background.js | 4 + js/modules/web_api.js | 678 +++++++++++++++++++++ libtextsecure/account_manager.js | 3 +- libtextsecure/api.js | 517 ---------------- libtextsecure/message_receiver.js | 4 +- libtextsecure/sendmessage.js | 2 +- libtextsecure/test/account_manager_test.js | 9 - libtextsecure/test/fake_api.js | 29 - libtextsecure/test/fake_web_api.js | 56 ++ libtextsecure/test/index.html | 11 +- libtextsecure/test/protocol_test.js | 2 - main.js | 2 +- preload.js | 14 +- test/index.html | 3 + 16 files changed, 763 insertions(+), 577 deletions(-) create mode 100644 js/modules/web_api.js delete mode 100644 libtextsecure/api.js delete mode 100644 libtextsecure/test/fake_api.js create mode 100644 libtextsecure/test/fake_web_api.js diff --git a/Gruntfile.js b/Gruntfile.js index 8e0094da0e4..ef6d949c834 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -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', diff --git a/config/default.json b/config/default.json index ef464eda449..7d711400a3b 100644 --- a/config/default.json +++ b/config/default.json @@ -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 } diff --git a/js/background.js b/js/background.js index 85296a90fd2..d0a316df6a2 100644 --- a/js/background.js +++ b/js/background.js @@ -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; diff --git a/js/modules/web_api.js b/js/modules/web_api.js new file mode 100644 index 00000000000..791fd74224e --- /dev/null +++ b/js/modules/web_api.js @@ -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 } + ); + } + } +} diff --git a/libtextsecure/account_manager.js b/libtextsecure/account_manager.js index 9498c673dc9..454a0c507e3 100644 --- a/libtextsecure/account_manager.js +++ b/libtextsecure/account_manager.js @@ -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) ); }, diff --git a/libtextsecure/api.js b/libtextsecure/api.js deleted file mode 100644 index 3cc45c47b66..00000000000 --- a/libtextsecure/api.js +++ /dev/null @@ -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; -})(); diff --git a/libtextsecure/message_receiver.js b/libtextsecure/message_receiver.js index c09e23b47b8..ce1f885e0a1 100644 --- a/libtextsecure/message_receiver.js +++ b/libtextsecure/message_receiver.js @@ -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(); diff --git a/libtextsecure/sendmessage.js b/libtextsecure/sendmessage.js index 9e465c1e20e..1a0d7f9f7c7 100644 --- a/libtextsecure/sendmessage.js +++ b/libtextsecure/sendmessage.js @@ -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 = {}; } diff --git a/libtextsecure/test/account_manager_test.js b/libtextsecure/test/account_manager_test.js index 1d25f4a9704..ebb03fc23b8 100644 --- a/libtextsecure/test/account_manager_test.js +++ b/libtextsecure/test/account_manager_test.js @@ -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(); diff --git a/libtextsecure/test/fake_api.js b/libtextsecure/test/fake_api.js deleted file mode 100644 index 225f8839bfb..00000000000 --- a/libtextsecure/test/fake_api.js +++ /dev/null @@ -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; - } -}; diff --git a/libtextsecure/test/fake_web_api.js b/libtextsecure/test/fake_web_api.js new file mode 100644 index 00000000000..fb252493270 --- /dev/null +++ b/libtextsecure/test/fake_web_api.js @@ -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, +}; diff --git a/libtextsecure/test/index.html b/libtextsecure/test/index.html index 1c6610f2077..ca6acd91f1e 100644 --- a/libtextsecure/test/index.html +++ b/libtextsecure/test/index.html @@ -12,6 +12,8 @@
+ + @@ -33,8 +35,6 @@ - - @@ -47,11 +47,12 @@ - + + - + diff --git a/libtextsecure/test/protocol_test.js b/libtextsecure/test/protocol_test.js index 24ef1c86699..fb4f5a3355c 100644 --- a/libtextsecure/test/protocol_test.js +++ b/libtextsecure/test/protocol_test.js @@ -33,6 +33,4 @@ describe('Protocol', function() { .catch(done); }); }); - - // TODO: Use fake_api's hiding of api.sendMessage to test sendmessage.js' maze }); diff --git a/main.js b/main.js index b398283fc6f..6c7bbdf3130 100644 --- a/main.js +++ b/main.js @@ -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(), diff --git a/preload.js b/preload.js index ea520d47f03..28b00b840c4 100644 --- a/preload.js +++ b/preload.js @@ -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: diff --git a/test/index.html b/test/index.html index e2a93938082..5bfff87b70e 100644 --- a/test/index.html +++ b/test/index.html @@ -564,6 +564,8 @@ + + @@ -653,6 +655,7 @@ +