session-desktop/libtextsecure/message_receiver.js

1546 lines
50 KiB
JavaScript

/* global window: false */
/* global callWorker: false */
/* global textsecure: false */
/* global StringView: false */
/* global libloki: false */
/* global libsignal: false */
/* global WebSocket: false */
/* global Event: false */
/* global dcodeIO: false */
/* global _: false */
/* global HttpResource: false */
/* global ContactBuffer: false */
/* global GroupBuffer: false */
/* global WebSocketResource: false */
/* eslint-disable more/no-then */
/* eslint-disable no-unreachable */
function MessageReceiver(username, password, signalingKey, options = {}) {
this.count = 0;
this.signalingKey = signalingKey;
this.username = username;
this.password = password;
this.lokiMessageAPI = window.LokiMessageAPI;
if (!options.serverTrustRoot) {
throw new Error('Server trust root is required!');
}
this.serverTrustRoot = window.Signal.Crypto.base64ToArrayBuffer(
options.serverTrustRoot
);
const address = libsignal.SignalProtocolAddress.fromString(username);
this.number = address.getName();
this.deviceId = address.getDeviceId();
this.pending = Promise.resolve();
if (options.retryCached) {
this.pending = this.queueAllCached();
}
}
MessageReceiver.stringToArrayBuffer = string =>
Promise.resolve(dcodeIO.ByteBuffer.wrap(string, 'binary').toArrayBuffer());
MessageReceiver.arrayBufferToString = arrayBuffer =>
Promise.resolve(dcodeIO.ByteBuffer.wrap(arrayBuffer).toString('binary'));
MessageReceiver.stringToArrayBufferBase64 = string =>
callWorker('stringToArrayBufferBase64', string);
MessageReceiver.arrayBufferToStringBase64 = arrayBuffer =>
callWorker('arrayBufferToStringBase64', arrayBuffer);
MessageReceiver.prototype = new textsecure.EventTarget();
MessageReceiver.prototype.extend({
constructor: MessageReceiver,
connect() {
if (this.calledClose) {
return;
}
this.count = 0;
if (this.hasConnected) {
const ev = new Event('reconnect');
this.dispatchEvent(ev);
}
this.hasConnected = true;
this.httpPollingResource = new HttpResource(this.lokiMessageAPI, {
handleRequest: this.handleRequest.bind(this),
});
this.httpPollingResource.startPolling(connected => {
// Emulate receiving an 'empty' websocket messages from the server.
// This is required to update the internal logic that checks
// if we are connected to the server. Without this, for example,
// the loading screen would never disappear if the navigator
// detects internet connectivity but never receives an 'empty' signal.
if (connected) {
this.onEmpty();
}
});
// TODO: Rework this socket stuff to work with online messaging
const useWebSocket = false;
if (useWebSocket) {
if (this.socket && this.socket.readyState !== WebSocket.CLOSED) {
this.socket.close();
this.wsr.close();
}
// initialize the socket and start listening for messages
this.socket = this.server.getMessageSocket();
this.socket.onclose = this.onclose.bind(this);
this.socket.onerror = this.onerror.bind(this);
this.socket.onopen = this.onopen.bind(this);
this.wsr = new WebSocketResource(this.socket, {
handleRequest: this.handleRequest.bind(this),
keepalive: {
path: '/v1/keepalive',
disconnect: true,
},
});
// Because sometimes the socket doesn't properly emit its close event
this._onClose = this.onclose.bind(this);
this.wsr.addEventListener('close', this._onClose);
}
// Ensures that an immediate 'empty' event from the websocket will fire only after
// all cached envelopes are processed.
this.incoming = [this.pending];
},
shutdown() {
if (this.socket) {
this.socket.onclose = null;
this.socket.onerror = null;
this.socket.onopen = null;
this.socket = null;
}
if (this.wsr) {
this.wsr.removeEventListener('close', this._onClose);
this.wsr = null;
}
},
close() {
window.log.info('MessageReceiver.close()');
this.calledClose = true;
// Our WebSocketResource instance will close the socket and emit a 'close' event
// if the socket doesn't emit one quickly enough.
if (this.wsr) {
this.wsr.close(3000, 'called close');
}
return this.drain();
},
onopen() {
window.log.info('websocket open');
},
onerror() {
window.log.error('websocket error');
},
dispatchAndWait(event) {
return Promise.all(this.dispatchEvent(event));
},
onclose(ev) {
window.log.info(
'websocket closed',
ev.code,
ev.reason || '',
'calledClose:',
this.calledClose
);
// TODO: handle properly
// this.shutdown();
// if (this.calledClose) {
// return Promise.resolve();
// }
// if (ev.code === 3000) {
// return Promise.resolve();
// }
// if (ev.code === 3001) {
// this.onEmpty();
// }
// // possible 403 or network issue. Make an request to confirm
// return this.server
// .getDevices(this.number)
// .then(this.connect.bind(this)) // No HTTP error? Reconnect
// .catch(e => {
// const event = new Event('error');
// event.error = e;
// return this.dispatchAndWait(event);
// });
},
handleRequest(request) {
this.incoming = this.incoming || [];
const lastPromise = _.last(this.incoming);
// We do the message decryption here, instead of in the ordered pending queue,
// to avoid exposing the time it took us to process messages through the time-to-ack.
if (request.path !== '/api/v1/message') {
window.log.info('got request', request.verb, request.path);
request.respond(200, 'OK');
if (request.verb === 'PUT' && request.path === '/api/v1/queue/empty') {
this.onEmpty();
}
return;
}
const promise = Promise.resolve(request.body.toArrayBuffer()) // textsecure.crypto
.then(plaintext => {
const envelope = textsecure.protobuf.Envelope.decode(plaintext);
// After this point, decoding errors are not the server's
// fault, and we should handle them gracefully and tell the
// user they received an invalid message
if (this.isBlocked(envelope.source)) {
return request.respond(200, 'OK');
}
envelope.id = envelope.serverGuid || window.getGuid();
envelope.serverTimestamp = envelope.serverTimestamp
? envelope.serverTimestamp.toNumber()
: null;
return this.addToCache(envelope, plaintext).then(
async () => {
request.respond(200, 'OK');
// To ensure that we queue in the same order we receive messages
await lastPromise;
this.queueEnvelope(envelope);
},
error => {
request.respond(500, 'Failed to cache message');
window.log.error(
'handleRequest error trying to add message to cache:',
error && error.stack ? error.stack : error
);
}
);
})
.catch(e => {
request.respond(500, 'Bad encrypted websocket message');
window.log.error(
'Error handling incoming message:',
e && e.stack ? e.stack : e
);
const ev = new Event('error');
ev.error = e;
return this.dispatchAndWait(ev);
});
this.incoming.push(promise);
},
addToQueue(task) {
this.count += 1;
this.pending = this.pending.then(task, task);
const { count, pending } = this;
const cleanup = () => {
this.updateProgress(count);
// We want to clear out the promise chain whenever possible because it could
// lead to large memory usage over time:
// https://github.com/nodejs/node/issues/6673#issuecomment-244331609
if (this.pending === pending) {
this.pending = Promise.resolve();
}
};
pending.then(cleanup, cleanup);
return pending;
},
onEmpty() {
const { incoming } = this;
this.incoming = [];
const dispatchEmpty = () => {
const ev = new Event('empty');
return this.dispatchAndWait(ev);
};
const queueDispatch = () => {
// resetting count to zero so everything queued after this starts over again
this.count = 0;
this.addToQueue(dispatchEmpty);
};
// We first wait for all recently-received messages (this.incoming) to be queued,
// then we add a task to emit the 'empty' event to the queue, so all message
// processing is complete by the time it runs.
Promise.all(incoming).then(queueDispatch, queueDispatch);
},
drain() {
const { incoming } = this;
this.incoming = [];
const queueDispatch = () =>
this.addToQueue(() => {
window.log.info('drained');
});
// This promise will resolve when there are no more messages to be processed.
return Promise.all(incoming).then(queueDispatch, queueDispatch);
},
updateProgress(count) {
// count by 10s
if (count % 10 !== 0) {
return;
}
const ev = new Event('progress');
ev.count = count;
this.dispatchEvent(ev);
},
async queueAllCached() {
const items = await this.getAllFromCache();
for (let i = 0, max = items.length; i < max; i += 1) {
// eslint-disable-next-line no-await-in-loop
await this.queueCached(items[i]);
}
},
async queueCached(item) {
try {
let envelopePlaintext = item.envelope;
if (item.version === 2) {
envelopePlaintext = await MessageReceiver.stringToArrayBufferBase64(
envelopePlaintext
);
}
if (typeof envelopePlaintext === 'string') {
envelopePlaintext = await MessageReceiver.stringToArrayBuffer(
envelopePlaintext
);
}
const envelope = textsecure.protobuf.Envelope.decode(envelopePlaintext);
envelope.id = envelope.serverGuid || item.id;
envelope.source = envelope.source || item.source;
envelope.sourceDevice = envelope.sourceDevice || item.sourceDevice;
envelope.serverTimestamp =
envelope.serverTimestamp || item.serverTimestamp;
envelope.preKeyBundleMessage =
envelope.preKeyBundleMessage || item.preKeyBundleMessage;
const { decrypted } = item;
if (decrypted) {
let payloadPlaintext = decrypted;
if (item.version === 2) {
payloadPlaintext = await MessageReceiver.stringToArrayBufferBase64(
payloadPlaintext
);
}
if (typeof payloadPlaintext === 'string') {
payloadPlaintext = await MessageReceiver.stringToArrayBuffer(
payloadPlaintext
);
}
// Convert preKeys to array buffer
if (typeof envelope.preKeyBundleMessage === 'string') {
envelope.preKeyBundleMessage = await MessageReceiver.stringToArrayBuffer(
envelope.preKeyBundleMessage
);
}
this.queueDecryptedEnvelope(envelope, payloadPlaintext);
} else {
this.queueEnvelope(envelope);
}
} catch (error) {
window.log.error(
'queueCached error handling item',
item.id,
'removing it. Error:',
error && error.stack ? error.stack : error
);
try {
const { id } = item;
await textsecure.storage.unprocessed.remove(id);
} catch (deleteError) {
window.log.error(
'queueCached error deleting item',
item.id,
'Error:',
deleteError && deleteError.stack ? deleteError.stack : deleteError
);
}
}
},
getEnvelopeId(envelope) {
if (envelope.source) {
return `${envelope.source}.${
envelope.sourceDevice
} ${envelope.timestamp.toNumber()} (${envelope.id})`;
}
return envelope.id;
},
async getAllFromCache() {
window.log.info('getAllFromCache');
const count = await textsecure.storage.unprocessed.getCount();
if (count > 1500) {
await textsecure.storage.unprocessed.removeAll();
window.log.warn(
`There were ${count} messages in cache. Deleted all instead of reprocessing`
);
return [];
}
const items = await textsecure.storage.unprocessed.getAll();
window.log.info('getAllFromCache loaded', items.length, 'saved envelopes');
return Promise.all(
_.map(items, async item => {
const attempts = 1 + (item.attempts || 0);
try {
if (attempts >= 3) {
window.log.warn(
'getAllFromCache final attempt for envelope',
item.id
);
await textsecure.storage.unprocessed.remove(item.id);
} else {
await textsecure.storage.unprocessed.save({ ...item, attempts });
}
} catch (error) {
window.log.error(
'getAllFromCache error updating item after load:',
error && error.stack ? error.stack : error
);
}
return item;
})
);
},
async addToCache(envelope, plaintext) {
const { id } = envelope;
const data = {
id,
version: 2,
envelope: await MessageReceiver.arrayBufferToStringBase64(plaintext),
timestamp: Date.now(),
attempts: 1,
};
return textsecure.storage.unprocessed.add(data);
},
async updateCache(envelope, plaintext) {
const { id } = envelope;
const item = await textsecure.storage.unprocessed.get(id);
if (!item) {
window.log.error(
`updateCache: Didn't find item ${id} in cache to update`
);
return null;
}
if (item.get('version') === 2) {
item.set({
source: envelope.source,
sourceDevice: envelope.sourceDevice,
serverTimestamp: envelope.serverTimestamp,
decrypted: await MessageReceiver.arrayBufferToStringBase64(plaintext),
});
} else {
item.set({
source: envelope.source,
sourceDevice: envelope.sourceDevice,
serverTimestamp: envelope.serverTimestamp,
decrypted: await MessageReceiver.arrayBufferToString(plaintext),
});
}
return textsecure.storage.unprocessed.save(item.attributes);
},
removeFromCache(envelope) {
const { id } = envelope;
return textsecure.storage.unprocessed.remove(id);
},
queueDecryptedEnvelope(envelope, plaintext) {
const { id } = envelope;
window.log.info('queueing decrypted envelope', id);
const task = this.handleDecryptedEnvelope.bind(this, envelope, plaintext);
const taskWithTimeout = textsecure.createTaskWithTimeout(
task,
`queueEncryptedEnvelope ${id}`
);
const promise = this.addToQueue(taskWithTimeout);
return promise.catch(error => {
window.log.error(
'queueDecryptedEnvelope error handling envelope',
id,
':',
error && error.stack ? error.stack : error
);
});
},
queueEnvelope(envelope) {
const id = this.getEnvelopeId(envelope);
window.log.info('queueing envelope', id);
const task = this.handleEnvelope.bind(this, envelope);
const taskWithTimeout = textsecure.createTaskWithTimeout(
task,
`queueEnvelope ${id}`
);
const promise = this.addToQueue(taskWithTimeout);
return promise.catch(error => {
window.log.error(
'queueEnvelope error handling envelope',
id,
':',
error && error.stack ? error.stack : error
);
});
},
// Same as handleEnvelope, just without the decryption step. Necessary for handling
// messages which were successfully decrypted, but application logic didn't finish
// processing.
handleDecryptedEnvelope(envelope, plaintext) {
// No decryption is required for delivery receipts, so the decrypted field of
// the Unprocessed model will never be set
if (envelope.content) {
return this.innerHandleContentMessage(envelope, plaintext);
} else if (envelope.legacyMessage) {
return this.innerHandleLegacyMessage(envelope, plaintext);
}
this.removeFromCache(envelope);
throw new Error('Received message with no content and no legacyMessage');
},
handleEnvelope(envelope) {
if (envelope.type === textsecure.protobuf.Envelope.Type.RECEIPT) {
return this.onDeliveryReceipt(envelope);
}
if (envelope.content) {
return this.handleContentMessage(envelope);
}
if (envelope.legacyMessage) {
return this.handleLegacyMessage(envelope);
}
this.removeFromCache(envelope);
throw new Error('Received message with no content and no legacyMessage');
},
getStatus() {
if (this.httpPollingResource) {
return this.httpPollingResource.isConnected()
? WebSocket.OPEN
: WebSocket.CLOSED;
}
if (this.socket) {
return this.socket.readyState;
} else if (this.hasConnected) {
return WebSocket.CLOSED;
}
return -1;
},
onDeliveryReceipt(envelope) {
return new Promise((resolve, reject) => {
const ev = new Event('delivery');
ev.confirm = this.removeFromCache.bind(this, envelope);
ev.deliveryReceipt = {
timestamp: envelope.timestamp.toNumber(),
source: envelope.source,
sourceDevice: envelope.sourceDevice,
};
this.dispatchAndWait(ev).then(resolve, reject);
});
},
unpad(paddedData) {
const paddedPlaintext = new Uint8Array(paddedData);
let plaintext;
for (let i = paddedPlaintext.length - 1; i >= 0; i -= 1) {
if (paddedPlaintext[i] === 0x80) {
plaintext = new Uint8Array(i);
plaintext.set(paddedPlaintext.subarray(0, i));
plaintext = plaintext.buffer;
break;
} else if (paddedPlaintext[i] !== 0x00) {
throw new Error('Invalid padding');
}
}
return plaintext;
},
async decrypt(envelope, ciphertext) {
const { serverTrustRoot } = this;
let promise;
const address = new libsignal.SignalProtocolAddress(
envelope.source,
envelope.sourceDevice
);
const ourNumber = textsecure.storage.user.getNumber();
const number = address.toString().split('.')[0];
const options = {};
// No limit on message keys if we're communicating with our other devices
if (ourNumber === number) {
options.messageKeysLimit = false;
}
const sessionCipher = new libsignal.SessionCipher(
textsecure.storage.protocol,
address,
options
);
const secretSessionCipher = new window.Signal.Metadata.SecretSessionCipher(
textsecure.storage.protocol
);
const fallBackSessionCipher = new libloki.crypto.FallBackSessionCipher(
address
);
const me = {
number: ourNumber,
deviceId: parseInt(textsecure.storage.user.getDeviceId(), 10),
};
let conversation;
try {
conversation = await window.ConversationController.getOrCreateAndWait(
envelope.source,
'private'
);
} catch (e) {
window.log.info('Error getting conversation: ', envelope.source);
}
const getCurrentSessionBaseKey = async () => {
const record = await sessionCipher.getRecord(address.toString());
if (!record) return null;
const openSession = record.getOpenSession();
if (!openSession) return null;
const { baseKey } = openSession.indexInfo;
return baseKey;
};
const captureActiveSession = async () => {
this.activeSessionBaseKey = await getCurrentSessionBaseKey(sessionCipher);
};
const restoreActiveSession = async () => {
const record = await sessionCipher.getRecord(address.toString());
if (!record) return;
record.archiveCurrentState();
const sessionToRestore = record.sessions[this.activeSessionBaseKey];
record.promoteState(sessionToRestore);
record.updateSessionState(sessionToRestore);
await textsecure.storage.protocol.storeSession(
address.toString(),
record.serialize()
);
};
const deleteAllSessionExcept = async sessionBaseKey => {
const record = await sessionCipher.getRecord(address.toString());
if (!record) return;
const sessionToKeep = record.sessions[sessionBaseKey];
record.sessions = {};
record.updateSessionState(sessionToKeep);
await textsecure.storage.protocol.storeSession(
address.toString(),
record.serialize()
);
};
let handleSessionReset;
if (conversation.isSessionResetOngoing()) {
handleSessionReset = async result => {
const currentSessionBaseKey = await getCurrentSessionBaseKey(
sessionCipher
);
if (
this.activeSessionBaseKey &&
currentSessionBaseKey !== this.activeSessionBaseKey
) {
if (conversation.isSessionResetReceived()) {
await restoreActiveSession();
} else {
await deleteAllSessionExcept(currentSessionBaseKey);
await conversation.onNewSessionAdopted();
}
} else if (conversation.isSessionResetReceived()) {
await deleteAllSessionExcept(this.activeSessionBaseKey);
await conversation.onNewSessionAdopted();
}
return result;
};
} else {
handleSessionReset = async result => result;
}
switch (envelope.type) {
case textsecure.protobuf.Envelope.Type.CIPHERTEXT:
window.log.info('message from', this.getEnvelopeId(envelope));
promise = captureActiveSession()
.then(() => sessionCipher.decryptWhisperMessage(ciphertext))
.then(this.unpad)
.then(handleSessionReset);
break;
case textsecure.protobuf.Envelope.Type.FRIEND_REQUEST: {
window.log.info('friend-request message from ', envelope.source);
promise = fallBackSessionCipher
.decrypt(ciphertext.toArrayBuffer())
.then(this.unpad);
break;
}
case textsecure.protobuf.Envelope.Type.PREKEY_BUNDLE:
window.log.info('prekey message from', this.getEnvelopeId(envelope));
promise = captureActiveSession(sessionCipher)
.then(() =>
this.decryptPreKeyWhisperMessage(ciphertext, sessionCipher, address)
)
.then(handleSessionReset);
break;
case textsecure.protobuf.Envelope.Type.UNIDENTIFIED_SENDER:
window.log.info('received unidentified sender message');
promise = secretSessionCipher
.decrypt(
window.Signal.Metadata.createCertificateValidator(serverTrustRoot),
ciphertext.toArrayBuffer(),
Math.min(envelope.serverTimestamp || Date.now(), Date.now()),
me
)
.then(
result => {
const { isMe, sender, content } = result;
// We need to drop incoming messages from ourself since server can't
// do it for us
if (isMe) {
return { isMe: true };
}
// Here we take this sender information and attach it back to the envelope
// to make the rest of the app work properly.
const originalSource = envelope.source;
// eslint-disable-next-line no-param-reassign
envelope.source = sender.getName();
// eslint-disable-next-line no-param-reassign
envelope.sourceDevice = sender.getDeviceId();
// eslint-disable-next-line no-param-reassign
envelope.unidentifiedDeliveryReceived = !originalSource;
// Return just the content because that matches the signature of the other
// decrypt methods used above.
return this.unpad(content);
},
error => {
const { sender } = error || {};
if (sender) {
const originalSource = envelope.source;
// eslint-disable-next-line no-param-reassign
envelope.source = sender.getName();
// eslint-disable-next-line no-param-reassign
envelope.sourceDevice = sender.getDeviceId();
// eslint-disable-next-line no-param-reassign
envelope.unidentifiedDeliveryReceived = !originalSource;
throw error;
}
return this.removeFromCache(envelope).then(() => {
throw error;
});
}
);
break;
default:
promise = Promise.reject(new Error('Unknown message type'));
}
return promise
.then(plaintext => {
const { isMe } = plaintext || {};
if (isMe) {
return this.removeFromCache(envelope);
}
return this.updateCache(envelope, plaintext).then(
() => plaintext,
error => {
window.log.error(
'decrypt failed to save decrypted message contents to cache:',
error && error.stack ? error.stack : error
);
return plaintext;
}
);
})
.catch(error => {
let errorToThrow = error;
if (error && error.message === 'Unknown identity key') {
// create an error that the UI will pick up and ask the
// user if they want to re-negotiate
const buffer = dcodeIO.ByteBuffer.wrap(ciphertext);
errorToThrow = new textsecure.IncomingIdentityKeyError(
address.toString(),
buffer.toArrayBuffer(),
error.identityKey
);
} else {
// re-throw
throw error;
}
const ev = new Event('error');
ev.error = errorToThrow;
ev.proto = envelope;
ev.confirm = this.removeFromCache.bind(this, envelope);
const returnError = () => Promise.reject(errorToThrow);
return this.dispatchAndWait(ev).then(returnError, returnError);
});
},
async decryptPreKeyWhisperMessage(ciphertext, sessionCipher, address) {
const padded = await sessionCipher.decryptPreKeyWhisperMessage(ciphertext);
try {
return this.unpad(padded);
} catch (e) {
if (e.message === 'Unknown identity key') {
// create an error that the UI will pick up and ask the
// user if they want to re-negotiate
const buffer = dcodeIO.ByteBuffer.wrap(ciphertext);
throw new textsecure.IncomingIdentityKeyError(
address.toString(),
buffer.toArrayBuffer(),
e.identityKey
);
}
throw e;
}
},
handleSentMessage(envelope, sentContainer, msg) {
const {
destination,
timestamp,
expirationStartTimestamp,
unidentifiedStatus,
} = sentContainer;
let p = Promise.resolve();
// eslint-disable-next-line no-bitwise
if (msg.flags & textsecure.protobuf.DataMessage.Flags.END_SESSION) {
p = this.handleEndSession(destination);
}
return p.then(() =>
this.processDecrypted(envelope, msg, this.number).then(message => {
const groupId = message.group && message.group.id;
const isBlocked = this.isGroupBlocked(groupId);
const isMe = envelope.source === textsecure.storage.user.getNumber();
const isLeavingGroup = Boolean(
message.group &&
message.group.type === textsecure.protobuf.GroupContext.Type.QUIT
);
if (groupId && isBlocked && !(isMe && isLeavingGroup)) {
window.log.warn(
`Message ${this.getEnvelopeId(
envelope
)} ignored; destined for blocked group`
);
return this.removeFromCache(envelope);
}
const ev = new Event('sent');
ev.confirm = this.removeFromCache.bind(this, envelope);
ev.data = {
destination,
timestamp: timestamp.toNumber(),
device: envelope.sourceDevice,
unidentifiedStatus,
message,
};
if (expirationStartTimestamp) {
ev.data.expirationStartTimestamp = expirationStartTimestamp.toNumber();
}
return this.dispatchAndWait(ev);
})
);
},
handleDataMessage(envelope, msg) {
window.log.info('data message from', this.getEnvelopeId(envelope));
let p = Promise.resolve();
// eslint-disable-next-line no-bitwise
if (msg.flags & textsecure.protobuf.DataMessage.Flags.END_SESSION) {
p = this.handleEndSession(envelope.source);
}
return p.then(() =>
this.processDecrypted(envelope, msg, envelope.source).then(message => {
const groupId = message.group && message.group.id;
const isBlocked = this.isGroupBlocked(groupId);
const isMe = envelope.source === textsecure.storage.user.getNumber();
const conversation = window.ConversationController.get(envelope.source);
const isLeavingGroup = Boolean(
message.group &&
message.group.type === textsecure.protobuf.GroupContext.Type.QUIT
);
const friendRequest =
envelope.type === textsecure.protobuf.Envelope.Type.FRIEND_REQUEST;
// Check if we need to update any profile names
if (!isMe && conversation) {
let profile = null;
if (message.profile) {
profile = JSON.parse(message.profile.encodeJSON());
}
// Update the conversation
conversation.setProfile(profile);
}
if (friendRequest && isMe) {
window.log.info('refusing to add a friend request to ourselves');
throw new Error('Cannot add a friend request for ourselves!');
}
if (groupId && isBlocked && !(isMe && isLeavingGroup)) {
window.log.warn(
`Message ${this.getEnvelopeId(
envelope
)} ignored; destined for blocked group`
);
return this.removeFromCache(envelope);
}
const ev = new Event('message');
ev.confirm = this.removeFromCache.bind(this, envelope);
ev.data = {
friendRequest,
source: envelope.source,
sourceDevice: envelope.sourceDevice,
timestamp: envelope.timestamp.toNumber(),
receivedAt: envelope.receivedAt,
unidentifiedDeliveryReceived: envelope.unidentifiedDeliveryReceived,
message,
};
return this.dispatchAndWait(ev);
})
);
},
handleLegacyMessage(envelope) {
return this.decrypt(envelope, envelope.legacyMessage).then(plaintext => {
if (!plaintext) {
window.log.warn('handleLegacyMessage: plaintext was falsey');
return null;
}
return this.innerHandleLegacyMessage(envelope, plaintext);
});
},
innerHandleLegacyMessage(envelope, plaintext) {
const message = textsecure.protobuf.DataMessage.decode(plaintext);
return this.handleDataMessage(envelope, message);
},
handleContentMessage(envelope) {
return this.decrypt(envelope, envelope.content).then(plaintext => {
if (!plaintext) {
window.log.warn('handleContentMessage: plaintext was falsey');
return null;
}
return this.innerHandleContentMessage(envelope, plaintext);
});
},
async innerHandleContentMessage(envelope, plaintext) {
const content = textsecure.protobuf.Content.decode(plaintext);
if (content.preKeyBundleMessage)
await this.savePreKeyBundleMessage(
envelope.source,
content.preKeyBundleMessage
);
if (content.syncMessage)
return this.handleSyncMessage(envelope, content.syncMessage);
if (content.dataMessage)
return this.handleDataMessage(envelope, content.dataMessage);
if (content.nullMessage)
return this.handleNullMessage(envelope, content.nullMessage);
if (content.callMessage)
return this.handleCallMessage(envelope, content.callMessage);
if (content.receiptMessage)
return this.handleReceiptMessage(envelope, content.receiptMessage);
if (content.typingMessage)
return this.handleTypingMessage(envelope, content.typingMessage);
// Trigger conversation friend request event for empty message
const conversation = window.ConversationController.get(envelope.source);
if (conversation) {
conversation.onFriendRequestAccepted();
conversation.notifyFriendRequest(envelope.source, 'accepted');
}
this.removeFromCache(envelope);
return null;
},
handleCallMessage(envelope) {
window.log.info('call message from', this.getEnvelopeId(envelope));
this.removeFromCache(envelope);
},
handleReceiptMessage(envelope, receiptMessage) {
const results = [];
if (
receiptMessage.type === textsecure.protobuf.ReceiptMessage.Type.DELIVERY
) {
for (let i = 0; i < receiptMessage.timestamp.length; i += 1) {
const ev = new Event('delivery');
ev.confirm = this.removeFromCache.bind(this, envelope);
ev.deliveryReceipt = {
timestamp: receiptMessage.timestamp[i].toNumber(),
source: envelope.source,
sourceDevice: envelope.sourceDevice,
};
results.push(this.dispatchAndWait(ev));
}
} else if (
receiptMessage.type === textsecure.protobuf.ReceiptMessage.Type.READ
) {
for (let i = 0; i < receiptMessage.timestamp.length; i += 1) {
const ev = new Event('read');
ev.confirm = this.removeFromCache.bind(this, envelope);
ev.timestamp = envelope.timestamp.toNumber();
ev.read = {
timestamp: receiptMessage.timestamp[i].toNumber(),
reader: envelope.source,
};
results.push(this.dispatchAndWait(ev));
}
}
return Promise.all(results);
},
handleTypingMessage(envelope, typingMessage) {
const ev = new Event('typing');
this.removeFromCache(envelope);
if (envelope.timestamp && typingMessage.timestamp) {
const envelopeTimestamp = envelope.timestamp.toNumber();
const typingTimestamp = typingMessage.timestamp.toNumber();
if (typingTimestamp !== envelopeTimestamp) {
window.log.warn(
`Typing message envelope timestamp (${envelopeTimestamp}) did not match typing timestamp (${typingTimestamp})`
);
return null;
}
}
ev.sender = envelope.source;
ev.senderDevice = envelope.sourceDevice;
ev.typing = {
typingMessage,
timestamp: typingMessage.timestamp
? typingMessage.timestamp.toNumber()
: Date.now(),
groupId: typingMessage.groupId
? typingMessage.groupId.toString('binary')
: null,
started:
typingMessage.action ===
textsecure.protobuf.TypingMessage.Action.STARTED,
stopped:
typingMessage.action ===
textsecure.protobuf.TypingMessage.Action.STOPPED,
};
return this.dispatchEvent(ev);
},
handleNullMessage(envelope) {
window.log.info('null message from', this.getEnvelopeId(envelope));
this.removeFromCache(envelope);
},
handleSyncMessage(envelope, syncMessage) {
if (envelope.source !== this.number) {
throw new Error('Received sync message from another number');
}
// eslint-disable-next-line eqeqeq
if (envelope.sourceDevice == this.deviceId) {
throw new Error('Received sync message from our own device');
}
if (syncMessage.sent) {
const sentMessage = syncMessage.sent;
const to = sentMessage.message.group
? `group(${sentMessage.message.group.id.toBinary()})`
: sentMessage.destination;
window.log.info(
'sent message to',
to,
sentMessage.timestamp.toNumber(),
'from',
this.getEnvelopeId(envelope)
);
return this.handleSentMessage(envelope, sentMessage, sentMessage.message);
} else if (syncMessage.contacts) {
return this.handleContacts(envelope, syncMessage.contacts);
} else if (syncMessage.groups) {
return this.handleGroups(envelope, syncMessage.groups);
} else if (syncMessage.blocked) {
return this.handleBlocked(envelope, syncMessage.blocked);
} else if (syncMessage.request) {
window.log.info('Got SyncMessage Request');
return this.removeFromCache(envelope);
} else if (syncMessage.read && syncMessage.read.length) {
window.log.info('read messages from', this.getEnvelopeId(envelope));
return this.handleRead(envelope, syncMessage.read);
} else if (syncMessage.verified) {
return this.handleVerified(envelope, syncMessage.verified);
} else if (syncMessage.configuration) {
return this.handleConfiguration(envelope, syncMessage.configuration);
}
throw new Error('Got empty SyncMessage');
},
handleConfiguration(envelope, configuration) {
window.log.info('got configuration sync message');
const ev = new Event('configuration');
ev.confirm = this.removeFromCache.bind(this, envelope);
ev.configuration = configuration;
return this.dispatchAndWait(ev);
},
handleVerified(envelope, verified) {
const ev = new Event('verified');
ev.confirm = this.removeFromCache.bind(this, envelope);
ev.verified = {
state: verified.state,
destination: verified.destination,
identityKey: verified.identityKey.toArrayBuffer(),
};
return this.dispatchAndWait(ev);
},
handleRead(envelope, read) {
const results = [];
for (let i = 0; i < read.length; i += 1) {
const ev = new Event('readSync');
ev.confirm = this.removeFromCache.bind(this, envelope);
ev.timestamp = envelope.timestamp.toNumber();
ev.read = {
timestamp: read[i].timestamp.toNumber(),
sender: read[i].sender,
};
results.push(this.dispatchAndWait(ev));
}
return Promise.all(results);
},
handleContacts(envelope, contacts) {
window.log.info('contact sync');
const attachmentPointer = contacts.blob;
return this.handleAttachment(attachmentPointer).then(() => {
const results = [];
const contactBuffer = new ContactBuffer(attachmentPointer.data);
let contactDetails = contactBuffer.next();
while (contactDetails !== undefined) {
const ev = new Event('contact');
ev.contactDetails = contactDetails;
results.push(this.dispatchAndWait(ev));
contactDetails = contactBuffer.next();
}
const ev = new Event('contactsync');
results.push(this.dispatchAndWait(ev));
return Promise.all(results).then(() => {
window.log.info('handleContacts: finished');
return this.removeFromCache(envelope);
});
});
},
handleGroups(envelope, groups) {
window.log.info('group sync');
const attachmentPointer = groups.blob;
return this.handleAttachment(attachmentPointer).then(() => {
const groupBuffer = new GroupBuffer(attachmentPointer.data);
let groupDetails = groupBuffer.next();
const promises = [];
while (groupDetails !== undefined) {
const getGroupDetails = details => {
// eslint-disable-next-line no-param-reassign
details.id = details.id.toBinary();
if (details.active) {
return textsecure.storage.groups
.getGroup(details.id)
.then(existingGroup => {
if (existingGroup === undefined) {
return textsecure.storage.groups.createNewGroup(
details.members,
details.id
);
}
return textsecure.storage.groups.updateNumbers(
details.id,
details.members
);
})
.then(() => details);
}
return Promise.resolve(details);
};
const promise = getGroupDetails(groupDetails)
.then(details => {
const ev = new Event('group');
ev.confirm = this.removeFromCache.bind(this, envelope);
ev.groupDetails = details;
return this.dispatchAndWait(ev);
})
.catch(e => {
window.log.error('error processing group', e);
});
groupDetails = groupBuffer.next();
promises.push(promise);
}
Promise.all(promises).then(() => {
const ev = new Event('groupsync');
ev.confirm = this.removeFromCache.bind(this, envelope);
return this.dispatchAndWait(ev);
});
});
},
handleBlocked(envelope, blocked) {
window.log.info('Setting these numbers as blocked:', blocked.numbers);
textsecure.storage.put('blocked', blocked.numbers);
const groupIds = _.map(blocked.groupIds, groupId => groupId.toBinary());
window.log.info(
'Setting these groups as blocked:',
groupIds.map(groupId => `group(${groupId})`)
);
textsecure.storage.put('blocked-groups', groupIds);
return this.removeFromCache(envelope);
},
async savePreKeyBundleMessage(pubKey, preKeyBundleMessage) {
const [identityKey, preKey, signedKey, signature] = [
preKeyBundleMessage.identityKey,
preKeyBundleMessage.preKey,
preKeyBundleMessage.signedKey,
preKeyBundleMessage.signature,
].map(k => dcodeIO.ByteBuffer.wrap(k).toArrayBuffer());
const { preKeyId, signedKeyId } = preKeyBundleMessage;
if (pubKey !== StringView.arrayBufferToHex(identityKey)) {
throw new Error(
'Error in savePreKeyBundleMessage: envelope pubkey does not match pubkey in prekey bundle'
);
}
await libloki.storage.saveContactPreKeyBundle({
pubKey,
preKeyId,
signedKeyId,
preKey,
signedKey,
signature,
});
},
isBlocked(number) {
return textsecure.storage.get('blocked', []).indexOf(number) >= 0;
},
isGroupBlocked(groupId) {
return textsecure.storage.get('blocked-groups', []).indexOf(groupId) >= 0;
},
handleAttachment(attachment) {
window.log.info('Not handling attachments.');
return Promise.reject();
// eslint-disable-next-line no-param-reassign
attachment.id = attachment.id.toString();
// eslint-disable-next-line no-param-reassign
attachment.key = attachment.key.toArrayBuffer();
if (attachment.digest) {
// eslint-disable-next-line no-param-reassign
attachment.digest = attachment.digest.toArrayBuffer();
}
function decryptAttachment(encrypted) {
return textsecure.crypto.decryptAttachment(
encrypted,
attachment.key,
attachment.digest
);
}
function updateAttachment(data) {
// eslint-disable-next-line no-param-reassign
attachment.data = data;
}
return this.server
.getAttachment(attachment.id)
.then(decryptAttachment)
.then(updateAttachment);
},
async handleEndSession(number) {
window.log.info('got end session');
const deviceIds = await textsecure.storage.protocol.getDeviceIds(number);
const identityKey = StringView.hexToArrayBuffer(number);
let conversation;
try {
conversation = window.ConversationController.get(number);
} catch (e) {
window.log.error('Error getting conversation: ', number);
}
// Bail early if a session reset is already ongoing
if (conversation.isSessionResetOngoing()) {
return;
}
await Promise.all(
deviceIds.map(async deviceId => {
const address = new libsignal.SignalProtocolAddress(number, deviceId);
// Instead of deleting the sessions now,
// we process the new prekeys and initiate a new session.
// The old sessions will get deleted once the correspondant
// has switch the the new session.
const [preKey, signedPreKey] = await Promise.all([
textsecure.storage.protocol.loadContactPreKey(number),
textsecure.storage.protocol.loadContactSignedPreKey(number),
]);
if (preKey === undefined || signedPreKey === undefined) {
return;
}
const device = {
identityKey,
deviceId,
preKey,
signedPreKey,
registrationId: 0,
};
const builder = new libsignal.SessionBuilder(
textsecure.storage.protocol,
address
);
builder.processPreKey(device);
})
);
await conversation.onSessionResetReceived();
},
processDecrypted(envelope, decrypted, source) {
/* eslint-disable no-bitwise, no-param-reassign */
const FLAGS = textsecure.protobuf.DataMessage.Flags;
// Now that its decrypted, validate the message and clean it up for consumer
// processing
// Note that messages may (generally) only perform one action and we ignore remaining
// fields after the first action.
if (decrypted.flags == null) {
decrypted.flags = 0;
}
if (decrypted.expireTimer == null) {
decrypted.expireTimer = 0;
}
if (decrypted.flags & FLAGS.END_SESSION) {
decrypted.body = null;
decrypted.attachments = [];
decrypted.group = null;
return Promise.resolve(decrypted);
} else if (decrypted.flags & FLAGS.EXPIRATION_TIMER_UPDATE) {
decrypted.body = null;
decrypted.attachments = [];
} else if (decrypted.flags & FLAGS.PROFILE_KEY_UPDATE) {
decrypted.body = null;
decrypted.attachments = [];
} else if (decrypted.flags !== 0) {
throw new Error('Unknown flags in message');
}
const promises = [];
if (decrypted.group !== null) {
decrypted.group.id = decrypted.group.id.toBinary();
if (
decrypted.group.type === textsecure.protobuf.GroupContext.Type.UPDATE
) {
if (decrypted.group.avatar !== null) {
promises.push(this.handleAttachment(decrypted.group.avatar));
}
}
const storageGroups = textsecure.storage.groups;
promises.push(
storageGroups.getNumbers(decrypted.group.id).then(existingGroup => {
if (existingGroup === undefined) {
if (
decrypted.group.type !==
textsecure.protobuf.GroupContext.Type.UPDATE
) {
decrypted.group.members = [source];
window.log.warn('Got message for unknown group');
}
return textsecure.storage.groups.createNewGroup(
decrypted.group.members,
decrypted.group.id
);
}
const fromIndex = existingGroup.indexOf(source);
if (fromIndex < 0) {
// TODO: This could be indication of a race...
window.log.warn(
'Sender was not a member of the group they were sending from'
);
}
switch (decrypted.group.type) {
case textsecure.protobuf.GroupContext.Type.UPDATE:
decrypted.body = null;
decrypted.attachments = [];
return textsecure.storage.groups.updateNumbers(
decrypted.group.id,
decrypted.group.members
);
case textsecure.protobuf.GroupContext.Type.QUIT:
decrypted.body = null;
decrypted.attachments = [];
if (source === this.number) {
return textsecure.storage.groups.deleteGroup(
decrypted.group.id
);
}
return textsecure.storage.groups.removeNumber(
decrypted.group.id,
source
);
case textsecure.protobuf.GroupContext.Type.DELIVER:
decrypted.group.name = null;
decrypted.group.members = [];
decrypted.group.avatar = null;
return Promise.resolve();
default:
this.removeFromCache(envelope);
throw new Error('Unknown group message type');
}
})
);
}
const attachmentCount = decrypted.attachments.length;
const ATTACHMENT_MAX = 32;
if (attachmentCount > ATTACHMENT_MAX) {
throw new Error(
`Too many attachments: ${attachmentCount} included in one message, max is ${ATTACHMENT_MAX}`
);
}
for (let i = 0; i < attachmentCount; i += 1) {
const attachment = decrypted.attachments[i];
promises.push(this.handleAttachment(attachment));
}
if (decrypted.contact && decrypted.contact.length) {
const contacts = decrypted.contact;
for (let i = 0, max = contacts.length; i < max; i += 1) {
const contact = contacts[i];
const { avatar } = contact;
if (avatar && avatar.avatar) {
// We don't want the failure of a thumbnail download to fail the handling of
// this message entirely, like we do for full attachments.
promises.push(
this.handleAttachment(avatar.avatar).catch(error => {
window.log.error(
'Problem loading avatar for contact',
error && error.stack ? error.stack : error
);
})
);
}
}
}
if (decrypted.quote && decrypted.quote.id) {
decrypted.quote.id = decrypted.quote.id.toNumber();
}
if (decrypted.quote && decrypted.quote.attachments) {
const { attachments } = decrypted.quote;
for (let i = 0, max = attachments.length; i < max; i += 1) {
const attachment = attachments[i];
const { thumbnail } = attachment;
if (thumbnail) {
// We don't want the failure of a thumbnail download to fail the handling of
// this message entirely, like we do for full attachments.
promises.push(
this.handleAttachment(thumbnail).catch(error => {
window.log.error(
'Problem loading thumbnail for quote',
error && error.stack ? error.stack : error
);
})
);
}
}
}
return Promise.all(promises).then(() => decrypted);
/* eslint-enable no-bitwise, no-param-reassign */
},
});
window.textsecure = window.textsecure || {};
textsecure.MessageReceiver = function MessageReceiverWrapper(
username,
password,
signalingKey,
options
) {
const messageReceiver = new MessageReceiver(
username,
password,
signalingKey,
options
);
this.addEventListener = messageReceiver.addEventListener.bind(
messageReceiver
);
this.removeEventListener = messageReceiver.removeEventListener.bind(
messageReceiver
);
this.getStatus = messageReceiver.getStatus.bind(messageReceiver);
this.close = messageReceiver.close.bind(messageReceiver);
this.savePreKeyBundleMessage = messageReceiver.savePreKeyBundleMessage.bind(
messageReceiver
);
messageReceiver.connect();
};
textsecure.MessageReceiver.prototype = {
constructor: textsecure.MessageReceiver,
};
textsecure.MessageReceiver.stringToArrayBuffer =
MessageReceiver.stringToArrayBuffer;
textsecure.MessageReceiver.arrayBufferToString =
MessageReceiver.arrayBufferToString;
textsecure.MessageReceiver.stringToArrayBufferBase64 =
MessageReceiver.stringToArrayBufferBase64;
textsecure.MessageReceiver.arrayBufferToStringBase64 =
MessageReceiver.arrayBufferToStringBase64;