// vim: ts=4:sw=4:expandtab
const ProvisioningCipher = require('../provisioning_cipher');
const errors = require('../errors');
const fetch = require('./fetch');
const libsignal = require('libsignal');
const protobufs = require('../protobufs');
const storage = require('../storage');
const SIGNAL_URL_CALLS = {
accounts: "/v1/accounts",
devices: "/v1/devices",
keys: "/v2/keys",
messages: "/v1/messages",
attachment: "/v1/attachments"
};
const SIGNAL_HTTP_MESSAGES = {
401: "Invalid authentication or invalidated registration",
403: "Invalid code",
404: "Address is not registered",
413: "Server rate limit exceeded",
417: "Address already registered"
};
/**
* Interface with the Signal server. The signal server handles the exchange
* of encrypted messages and brokering of public keys.
*/
class SignalClient {
constructor(username, password, url) {
this.url = url;
this.username = username;
this.password = password;
this.attachment_id_regex = RegExp("^https?://.*/(\\d+)?");
}
/**
* Return a default instance.
*/
static async factory() {
const url = await storage.getState('serverUrl');
const username = await storage.getState('username');
const password = await storage.getState('password');
return new this(username, password, url);
}
/**
* Respond positively to a provision request from a new foreign device.
* WARNING: This will send your private identity key to the requesting
* device!
*
* @param {string} uuid - UUID param provided by the foreign device.
* @param {string} pubKey - Ephemeral public key used for provisioning.
* @param {Object} [options]
* @param {string} [options.userAgent]
*/
async linkDevice(uuid, pubKey, options) {
options = options || {};
const provisionResp = await this.request({
call: 'devices',
urlParameters: '/provisioning/code'
});
const ourIdent = await storage.getOurIdentity();
const pMessage = new protobufs.ProvisionMessage();
pMessage.identityKeyPrivate = ourIdent.privKey;
pMessage.addr = await storage.getState('addr');
pMessage.userAgent = options.userAgent || 'librelay-web';
pMessage.provisioningCode = provisionResp.verificationCode;
const provisioningCipher = new ProvisioningCipher();
const pEnvelope = await provisioningCipher.encrypt(pubKey, pMessage);
const resp = await this.fetch('/v1/provisioning/' + uuid, {
method: 'PUT',
json: {
body: pEnvelope.toString('base64')
}
});
if (!resp.ok) {
// 404 means someone else handled it already.
if (resp.status !== 404) {
throw new Error(await resp.text());
}
}
}
/**
* Generate a new signed prekey and pool of prekeys if needed.
* If new keys are generated they are uploaded to the signal service
* so they can be used for new incoming messages.
*
* @param {number} [minLevel=10] - If less then this many keys are
* available then generate new keys.
* @param {number} [fill=100] - The number of new keys to generate when
* needed.
*/
async refreshPreKeys(minLevel=10, fill=100) {
const preKeyCount = await this.getMyKeys();
if (preKeyCount <= minLevel) {
// The server replaces existing keys so just go to the hilt.
console.info("Refreshing pre-keys...");
await this.registerKeys(await this.generateKeys(fill));
}
}
async generateKeys(count=100, progressCallback) {
if (typeof progressCallback !== 'function') {
progressCallback = undefined;
}
const startId = await storage.getState('maxPreKeyId') || 1;
if (typeof startId !== 'number') {
throw new Error('Invalid maxPreKeyId');
}
const signedKeyId = await storage.getState('signedKeyId') || 1;
if (typeof signedKeyId !== 'number') {
throw new Error('Invalid signedKeyId');
}
const ourIdent = await storage.getOurIdentity();
const result = {
preKeys: [],
identityKey: ourIdent.pubKey
};
for (let keyId = startId; keyId < startId + count; ++keyId) {
const preKey = libsignal.keyhelper.generatePreKey(keyId);
await storage.storePreKey(preKey.keyId, preKey.keyPair);
result.preKeys.push({
keyId: preKey.keyId,
publicKey: preKey.keyPair.pubKey
});
if (progressCallback) {
progressCallback(keyId - startId);
}
}
const sprekey = await libsignal.keyhelper.generateSignedPreKey(ourIdent, signedKeyId);
await storage.storeSignedPreKey(sprekey.keyId, sprekey.keyPair);
result.signedPreKey = {
keyId: sprekey.keyId,
publicKey: sprekey.keyPair.pubKey,
signature: sprekey.signature
};
await storage.removeSignedPreKey(signedKeyId - 2);
await storage.putState('maxPreKeyId', startId + count);
await storage.putState('signedKeyId', signedKeyId + 1);
return result;
}
authHeader(username, password) {
const token = Buffer.from(username + ':' + password).toString('base64');
return 'Basic ' + token;
}
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;
}
async request(param) {
if (!param.urlParameters) {
param.urlParameters = '';
}
const path = SIGNAL_URL_CALLS[param.call] + param.urlParameters;
const headers = new fetch.Headers();
if (param.username && param.password) {
headers.set('Authorization', this.authHeader(param.username, param.password));
}
let resp;
try {
resp = await this.fetch(path, {
method: param.httpType || 'GET',
json: param.json,
headers
});
} catch(e) {
/* Fetch throws a very boring TypeError, throw something better.. */
throw new errors.NetworkError(`${e.message}: ${param.call}`);
}
let resp_content;
if ((resp.headers.get('content-type') || '').startsWith('application/json')) {
resp_content = await resp.json();
} else {
resp_content = await resp.text();
}
if (!resp.ok) {
const e = new errors.ProtocolError(resp.status, resp_content);
if (SIGNAL_HTTP_MESSAGES.hasOwnProperty(e.code)) {
e.message = SIGNAL_HTTP_MESSAGES[e.code];
} else {
e.message = `Status code: ${e.code}`;
}
throw e;
}
if (resp.status !== 204) {
if (param.validateResponse &&
!this.validateResponse(resp_content, param.validateResponse)) {
throw new errors.ProtocolError(resp.status, resp_content);
}
return resp_content;
}
}
/**
* Authenticated fetch to the signal service.
*
* @param {string} urn
* @param {Object} [options]
* @returns {*} - Data response from service.
*/
async fetch(urn, options) {
/* Thin wrapper to augment json and auth support. */
options = options || {};
options.headers = options.headers || new fetch.Headers();
if (!options.headers.has('Authorization')) {
if (this.username && this.password) {
options.headers.set('Authorization', this.authHeader(this.username, this.password));
}
}
return await fetch(`${this.url}/${urn.replace(/^\//, '')}`, options);
}
/**
* Fetch the current list of known devices associated with your account.
*
* @returns {Array} - Array of device info objects.
*/
async getDevices() {
const data = await this.request({call: 'devices'});
return data && data.devices;
}
async registerKeys(genKeys) {
const json = {};
json.identityKey = genKeys.identityKey.toString('base64');
json.signedPreKey = {
keyId: genKeys.signedPreKey.keyId,
publicKey: genKeys.signedPreKey.publicKey.toString('base64'),
signature: genKeys.signedPreKey.signature.toString('base64')
};
json.preKeys = [];
var j = 0;
for (var i in genKeys.preKeys) {
json.preKeys[j++] = {
keyId: genKeys.preKeys[i].keyId,
publicKey: genKeys.preKeys[i].publicKey.toString('base64')
};
}
return await this.request({
call: 'keys',
httpType: 'PUT',
json
});
}
async getMyKeys() {
const res = await this.request({
call: 'keys',
validateResponse: {count: 'number'}
});
return res.count;
}
/**
* Get a public prekey and signed prekey for a peer.
*
* @param {string} addr
* @param {number} deviceId
* @returns {Object} - Key material object.
*/
async getKeysForAddr(addr, deviceId) {
if (deviceId === undefined) {
deviceId = "*";
}
const res = await this.request({
call: 'keys',
urlParameters: "/" + addr + "/" + deviceId,
validateResponse: {identityKey: 'string', devices: 'object'}
});
if (res.devices.constructor !== Array) {
throw new TypeError("Invalid response");
}
res.identityKey = Buffer.from(res.identityKey, 'base64');
for (const device of res.devices) {
if (!this.validateResponse(device, {signedPreKey: 'object'}) ||
!this.validateResponse(device.signedPreKey, {publicKey: 'string', signature: 'string'})) {
throw new TypeError("Invalid signedPreKey");
}
if (device.preKey) {
if (!this.validateResponse(device, {preKey: 'object'}) ||
!this.validateResponse(device.preKey, {publicKey: 'string'})) {
throw new TypeError("Invalid preKey");
}
device.preKey.publicKey = Buffer.from(device.preKey.publicKey, 'base64');
}
device.signedPreKey.publicKey = Buffer.from(device.signedPreKey.publicKey, 'base64');
device.signedPreKey.signature = Buffer.from(device.signedPreKey.signature, 'base64');
}
return res;
}
/**
* Transmit encrypted messages to the signal service.
*
* @param {string} destination - Address of peer
* @param {Array} messages - Array of encrypted messages
* @param {number} timestamp - Official timestamp for these messages.
*/
async sendMessages(destination, messages, timestamp) {
return await this.request({
call: 'messages',
httpType: 'PUT',
urlParameters: '/' + destination,
json: {
messages,
timestamp
}
});
}
/**
* Transmit a single encrytped message to the signal service.
* Use this when sending a message to just 1 peer device.
*
* @param {string} addr
* @param {number} deviceId
* @param {Object} message - Encrypted message
*/
async sendMessage(addr, deviceId, message) {
return await this.request({
call: 'messages',
httpType: 'PUT',
urlParameters: `/${addr}/${deviceId}`,
json: message
});
}
/**
* Download an encrypted attachment.
*
* @param {string} id - The remote ID to fetch.
* @returns {Buffer} - The encrypted attachment.
*/
async getAttachment(id) {
// XXX Build in retry handling...
const response = await this.request({
call: 'attachment',
urlParameters: '/' + id,
validateResponse: {location: 'string'}
});
const headers = new fetch.Headers({
'Content-Type': 'application/octet-stream',
});
const attachment = await fetch(response.location, {headers});
if (!attachment.ok) {
const msg = await attachment.text();
console.error("Download attachement error:", msg);
throw new Error('Download Attachment Error: ' + msg);
}
return await attachment.buffer();
}
/**
* Upload an encrypted attachment.
*
* @param {Buffer} - Encrypted attachment data.
* @returns {string} - The ID for this remote attachment.
*/
async putAttachment(body) {
// XXX Build in retry handling...
const ptrResp = await this.request({call: 'attachment'});
// Extract the id as a string from the location url
// (workaround for ids too large for Javascript numbers)
const match = ptrResp.location.match(this.attachment_id_regex);
if (!match) {
console.error('Invalid attachment url for outgoing message',
ptrResp.location);
throw new TypeError('Received invalid attachment url');
}
const headers = new fetch.Headers({
'Content-Type': 'application/octet-stream',
'Content-Length': body.byteLength // See: https://github.com/bitinn/node-fetch/issues/47
});
const dataResp = await fetch(ptrResp.location, {
method: "PUT",
headers,
body
});
if (!dataResp.ok) {
const msg = await dataResp.text();
console.error("Upload attachment error:", msg);
throw new Error('Upload Attachment Error: ' + msg);
}
return match[1];
}
getMessageWebSocketURL() {
return [
this.url.replace('https://', 'wss://').replace('http://', 'ws://'),
'/v1/websocket/?login=', encodeURIComponent(this.username),
'&password=', encodeURIComponent(this.password)].join('');
}
getProvisioningWebSocketURL () {
return this.url.replace('https://', 'wss://').replace('http://', 'ws://') +
'/v1/websocket/provisioning/';
}
/**
* The GCM reg ID configures the data needed to wake us up using google cloud messaging
* service. No data other than a "wakeup" is sent through this service.
*
* @param {string} gcmRegistrationId
*/
async updateGcmRegistrationId(gcmRegistrationId) {
return await this.request({
call: 'accounts',
httpType: 'PUT',
urlParameters: '/gcm',
json: {gcmRegistrationId}
});
}
}
module.exports = SignalClient;