
We pull proxy settings from environment variables: - HTTPS_PROXY for sending, profile pulls, and attachment download/upload - WSS_PROXY for connecting to the websocket for receiving messages - ALL_PROXY to provide one server for both More details on our proxy handling: - https://github.com/Rob--W/proxy-from-env#environment-variables - https://github.com/TooTallNate/node-proxy-agent This is the natural way of things for Linux. My understanding is that most proxies on MacOS are system-wide and transparent, so it's not so urgent. But Windows will likely require further UI for configuration. Will need to do some testing with Windows users.
448 lines
17 KiB
JavaScript
448 lines
17 KiB
JavaScript
/*
|
|
* vim: ts=4:sw=4:expandtab
|
|
*/
|
|
|
|
var TextSecureServer = (function() {
|
|
'use strict';
|
|
|
|
function validateResponse(response, schema) {
|
|
try {
|
|
for (var i in schema) {
|
|
switch (schema[i]) {
|
|
case 'object':
|
|
case 'string':
|
|
case 'number':
|
|
if (typeof response[i] !== schema[i]) {
|
|
return false;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
} catch(ex) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function createSocket(url) {
|
|
var proxyUrl = window.getProxyForUrl(url);
|
|
var requestOptions;
|
|
if (proxyUrl) {
|
|
console.log('createSocket: using proxy url', proxyUrl);
|
|
requestOptions = {
|
|
agent: ProxyAgent(proxyUrl)
|
|
};
|
|
} else {
|
|
requestOptions = {
|
|
ca: window.config.certificateAuthorities,
|
|
};
|
|
}
|
|
|
|
return new nodeWebSocket(url, null, null, null, requestOptions);
|
|
}
|
|
|
|
window.setImmediate = nodeSetImmediate;
|
|
|
|
function promise_ajax(url, options) {
|
|
return new Promise(function (resolve, reject) {
|
|
if (!url) {
|
|
url = options.host + '/' + options.path;
|
|
}
|
|
console.log(options.type, url);
|
|
var timeout = typeof options.timeout !== 'undefined' ? options.timeout : 10000;
|
|
|
|
var proxyUrl = window.getProxyForUrl(url);
|
|
var agent;
|
|
if (proxyUrl) {
|
|
console.log('promixe_ajax: using proxy url', proxyUrl);
|
|
agent = new ProxyAgent(proxyUrl);
|
|
} else {
|
|
agent = new httpsAgent({
|
|
ca: options.certificateAuthorities
|
|
});
|
|
}
|
|
|
|
var fetchOptions = {
|
|
method: options.type,
|
|
body: options.data || null,
|
|
headers: { 'X-Signal-Agent': 'OWD' },
|
|
agent: agent,
|
|
timeout: timeout,
|
|
};
|
|
|
|
if (fetchOptions.body instanceof ArrayBuffer) {
|
|
// node-fetch doesn't support ArrayBuffer, only node Buffer
|
|
var 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) {
|
|
fetchOptions.headers["Authorization"] = "Basic " + btoa(getString(options.user) + ":" + getString(options.password));
|
|
}
|
|
if (options.contentType) {
|
|
fetchOptions.headers["Content-Type"] = options.contentType;
|
|
}
|
|
window.nodeFetch(url, fetchOptions).then(function(response) {
|
|
var 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(function(result) {
|
|
if (options.responseType === 'arraybuffer') {
|
|
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(response.status, result, options.stack));
|
|
}
|
|
}
|
|
}
|
|
if (0 <= response.status && 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(response.status, result, options.stack));
|
|
}
|
|
});
|
|
}).catch(function(e) {
|
|
console.log(options.type, url, 0, 'Error');
|
|
console.log(e);
|
|
reject(HTTPError(0, e.toString(), options.stack));
|
|
});
|
|
});
|
|
}
|
|
|
|
function retry_ajax(url, options, limit, count) {
|
|
count = count || 0;
|
|
limit = limit || 3;
|
|
count++;
|
|
return promise_ajax(url, options).catch(function(e) {
|
|
if (e.name === 'HTTPError' && e.code === -1 && count < limit) {
|
|
return new Promise(function(resolve) {
|
|
setTimeout(function() {
|
|
resolve(retry_ajax(url, options, limit, count));
|
|
}, 1000);
|
|
});
|
|
} else {
|
|
throw e;
|
|
}
|
|
});
|
|
}
|
|
|
|
function ajax(url, options) {
|
|
options.stack = new Error().stack; // just in case, save stack here.
|
|
return retry_ajax(url, options);
|
|
}
|
|
|
|
function HTTPError(code, response, stack) {
|
|
if (code > 999 || code < 100) {
|
|
code = -1;
|
|
}
|
|
var e = new Error();
|
|
e.name = 'HTTPError';
|
|
e.code = code;
|
|
e.stack = stack;
|
|
if (response) {
|
|
e.response = response;
|
|
}
|
|
return e;
|
|
}
|
|
|
|
var URL_CALLS = {
|
|
accounts : "v1/accounts",
|
|
devices : "v1/devices",
|
|
keys : "v2/keys",
|
|
signed : "v2/keys/signed",
|
|
messages : "v1/messages",
|
|
attachment : "v1/attachments",
|
|
profile : "v1/profile"
|
|
};
|
|
|
|
function TextSecureServer(url, username, password, cdn_url) {
|
|
if (typeof url !== 'string') {
|
|
throw new Error('Invalid server url');
|
|
}
|
|
this.url = url;
|
|
this.cdn_url = cdn_url;
|
|
this.username = username;
|
|
this.password = password;
|
|
}
|
|
|
|
TextSecureServer.prototype = {
|
|
constructor: TextSecureServer,
|
|
ajax: function(param) {
|
|
if (!param.urlParameters) {
|
|
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(function(e) {
|
|
var code = e.code;
|
|
if (code === 200) {
|
|
// happens sometimes when we get no response
|
|
// (TODO: Fix server to return 204? instead)
|
|
return null;
|
|
}
|
|
var 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: function(number) {
|
|
return this.ajax({
|
|
call : 'profile',
|
|
httpType : 'GET',
|
|
urlParameters : '/' + number,
|
|
responseType : 'json',
|
|
});
|
|
},
|
|
getAvatar: function(path) {
|
|
return ajax(this.cdn_url + '/' + path, {
|
|
type : "GET",
|
|
responseType: "arraybuffer",
|
|
contentType : "application/octet-stream",
|
|
certificateAuthorities: window.config.certificateAuthorities,
|
|
timeout: 0
|
|
});
|
|
},
|
|
requestVerificationSMS: function(number) {
|
|
return this.ajax({
|
|
call : 'accounts',
|
|
httpType : 'GET',
|
|
urlParameters : '/sms/code/' + number,
|
|
});
|
|
},
|
|
requestVerificationVoice: function(number) {
|
|
return this.ajax({
|
|
call : 'accounts',
|
|
httpType : 'GET',
|
|
urlParameters : '/voice/code/' + number,
|
|
});
|
|
},
|
|
confirmCode: function(number, code, password, signaling_key, registrationId, deviceName) {
|
|
var jsonData = {
|
|
signalingKey : btoa(getString(signaling_key)),
|
|
supportsSms : false,
|
|
fetchesMessages : true,
|
|
registrationId : registrationId,
|
|
};
|
|
|
|
var call, urlPrefix, schema, 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 : call,
|
|
httpType : 'PUT',
|
|
urlParameters : urlPrefix + code,
|
|
jsonData : jsonData,
|
|
responseType : responseType,
|
|
validateResponse : schema
|
|
});
|
|
},
|
|
getDevices: function(number) {
|
|
return this.ajax({
|
|
call : 'devices',
|
|
httpType : 'GET',
|
|
});
|
|
},
|
|
registerKeys: function(genKeys) {
|
|
var 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 = [];
|
|
var j = 0;
|
|
for (var i in genKeys.preKeys) {
|
|
keys.preKeys[j++] = {
|
|
keyId: genKeys.preKeys[i].keyId,
|
|
publicKey: btoa(getString(genKeys.preKeys[i].publicKey))
|
|
};
|
|
}
|
|
|
|
// 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: function(signedPreKey) {
|
|
return this.ajax({
|
|
call : 'signed',
|
|
httpType : 'PUT',
|
|
jsonData : {
|
|
keyId: signedPreKey.keyId,
|
|
publicKey: btoa(getString(signedPreKey.publicKey)),
|
|
signature: btoa(getString(signedPreKey.signature))
|
|
}
|
|
});
|
|
},
|
|
getMyKeys: function(number, deviceId) {
|
|
return this.ajax({
|
|
call : 'keys',
|
|
httpType : 'GET',
|
|
responseType : 'json',
|
|
validateResponse : {count: 'number'}
|
|
}).then(function(res) {
|
|
return res.count;
|
|
});
|
|
},
|
|
getKeysForNumber: function(number, deviceId) {
|
|
if (deviceId === undefined)
|
|
deviceId = "*";
|
|
|
|
return this.ajax({
|
|
call : 'keys',
|
|
httpType : 'GET',
|
|
urlParameters : "/" + number + "/" + deviceId,
|
|
responseType : 'json',
|
|
validateResponse : {identityKey: 'string', devices: 'object'}
|
|
}).then(function(res) {
|
|
if (res.devices.constructor !== Array) {
|
|
throw new Error("Invalid response");
|
|
}
|
|
res.identityKey = StringView.base64ToBytes(res.identityKey);
|
|
res.devices.forEach(function(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");
|
|
}
|
|
device.preKey.publicKey = StringView.base64ToBytes(device.preKey.publicKey);
|
|
}
|
|
device.signedPreKey.publicKey = StringView.base64ToBytes(device.signedPreKey.publicKey);
|
|
device.signedPreKey.signature = StringView.base64ToBytes(device.signedPreKey.signature);
|
|
});
|
|
return res;
|
|
});
|
|
},
|
|
sendMessages: function(destination, messageArray, timestamp, silent) {
|
|
var jsonData = { messages: messageArray, timestamp: timestamp};
|
|
|
|
if (silent) {
|
|
jsonData.silent = true;
|
|
}
|
|
|
|
return this.ajax({
|
|
call : 'messages',
|
|
httpType : 'PUT',
|
|
urlParameters : '/' + destination,
|
|
jsonData : jsonData,
|
|
responseType : 'json',
|
|
});
|
|
},
|
|
getAttachment: function(id) {
|
|
return this.ajax({
|
|
call : 'attachment',
|
|
httpType : 'GET',
|
|
urlParameters : '/' + id,
|
|
responseType : 'json',
|
|
validateResponse : {location: 'string'},
|
|
}).then(function(response) {
|
|
return ajax(response.location, {
|
|
timeout : 0,
|
|
type : "GET",
|
|
responseType: "arraybuffer",
|
|
contentType : "application/octet-stream"
|
|
});
|
|
}.bind(this));
|
|
},
|
|
putAttachment: function(encryptedBin) {
|
|
return this.ajax({
|
|
call : 'attachment',
|
|
httpType : 'GET',
|
|
responseType : 'json',
|
|
}).then(function(response) {
|
|
return ajax(response.location, {
|
|
timeout : 0,
|
|
type : "PUT",
|
|
contentType : "application/octet-stream",
|
|
data : encryptedBin,
|
|
processData : false,
|
|
}).then(function() {
|
|
return response.idString;
|
|
}.bind(this));
|
|
}.bind(this));
|
|
},
|
|
getMessageSocket: function() {
|
|
console.log('opening message socket', this.url);
|
|
return createSocket(this.url.replace('https://', 'wss://').replace('http://', 'ws://')
|
|
+ '/v1/websocket/?login=' + encodeURIComponent(this.username)
|
|
+ '&password=' + encodeURIComponent(this.password)
|
|
+ '&agent=OWD');
|
|
},
|
|
getProvisioningSocket: function () {
|
|
console.log('opening provisioning socket', this.url);
|
|
return createSocket(this.url.replace('https://', 'wss://').replace('http://', 'ws://')
|
|
+ '/v1/websocket/provisioning/?agent=OWD');
|
|
}
|
|
};
|
|
|
|
return TextSecureServer;
|
|
})();
|