You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
session-desktop/libtextsecure/api.js

518 lines
15 KiB
JavaScript

/* 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;
})();