signal-desktop/libtextsecure/api.js
Scott Nonnenberg ce01eb7913
Log more info w/http errors, resilient to invalid group members (#1968)
* api.js: HttpError now preserves more of original error info

* Allow invalid conversation to be added to ConversationController

If a number we don't believe is valid comes in as part of a group, we
fall apart. We event prevent display of that conversation. Because we
have 'isValid()' protections in place for the places we create contacts
from user input in desktop (the search bar), we can temporarily add an
invalid contact to our collection (not saving it to the database) to
unblock this group scenario.

Still investigating what kind of phone number is valid in a mobile app
but not valid for us.

* Finish the debuggable error support

* Fix logging in the case of an invalid number
2018-01-12 16:19:26 -08:00

458 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.config.proxyUrl;
var requestOptions;
if (proxyUrl) {
requestOptions = {
ca: window.config.certificateAuthorities,
agent: new 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.config.proxyUrl;
var agent;
if (proxyUrl) {
agent = new ProxyAgent(proxyUrl);
}
var fetchOptions = {
method: options.type,
body: options.data || null,
headers: { 'X-Signal-Agent': 'OWD' },
agent: agent,
ca: options.certificateAuthorities,
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(
'promise_ajax: invalid response',
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(
'promise_ajax: error response',
response.status,
result,
options.stack
));
}
});
}).catch(function(e) {
console.log(options.type, url, 0, 'Error');
var stack = e.stack + '\nInitial stack:\n' + options.stack;
reject(HTTPError('promise_ajax catch', 0, e.toString(), 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(message, code, response, stack) {
if (code > 999 || code < 100) {
code = -1;
}
var 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;
}
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;
})();