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/message_receiver.js

1548 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),
});
6 years ago
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
6 years ago
// 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.
// TODO: handle different types of requests.
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
// .decryptWebsocketMessage(request.body, this.signalingKey)
.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;
6 years ago
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
6 years ago
} ${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) {
6 years ago
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 {
6 years ago
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());
6 years ago
if (!record) return null;
const openSession = record.getOpenSession();
6 years ago
if (!openSession) return null;
const { baseKey } = openSession.indexInfo;
6 years ago
return baseKey;
};
const captureActiveSession = async () => {
this.activeSessionBaseKey = await getCurrentSessionBaseKey(sessionCipher);
};
const restoreActiveSession = async () => {
const record = await sessionCipher.getRecord(address.toString());
6 years ago
if (!record) return;
record.archiveCurrentState();
const sessionToRestore = record.sessions[this.activeSessionBaseKey];
record.promoteState(sessionToRestore);
record.updateSessionState(sessionToRestore);
6 years ago
await textsecure.storage.protocol.storeSession(
address.toString(),
record.serialize()
);
};
6 years ago
const deleteAllSessionExcept = async sessionBaseKey => {
const record = await sessionCipher.getRecord(address.toString());
6 years ago
if (!record) return;
const sessionToKeep = record.sessions[sessionBaseKey];
6 years ago
record.sessions = {};
record.updateSessionState(sessionToKeep);
6 years ago
await textsecure.storage.protocol.storeSession(
address.toString(),
record.serialize()
);
};
let handleSessionReset;
if (conversation.isSessionResetOngoing()) {
6 years ago
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 {
6 years ago
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);
6 years ago
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)
6 years ago
.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 &&
6 years ago
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 &&
6 years ago
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) {
6 years ago
window.log.info('refusing to add a friend request to ourselves');
throw new Error('Cannot add a friend request for ourselves!');
6 years ago
}
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');
Feature: Blue check marks for read messages if opted in (#1489) * Refactor delivery receipt event handler * Rename the delivery receipt event For less ambiguity with read receipts. * Rename synced read event For less ambiguity with read receipts from other Signal users. * Add support for incoming receipt messages Handle ReceiptMessages, which may include encrypted delivery receipts or read receipts from recipients of our sent messages. // FREEBIE * Rename ReadReceipts to ReadSyncs * Render read messages with blue double checks * Send read receipts to senders of incoming messages // FREEBIE * Move ReadSyncs to their own file // FREEBIE * Fixup old comments on read receipts (now read syncs) And some variable renaming for extra clarity. // FREEBIE * Add global setting for read receipts Don't send read receipt messages unless the setting is enabled. Don't process read receipts if the setting is disabled. // FREEBIE * Sync read receipt setting from mobile Toggling this setting on your mobile device should sync it to Desktop. When linking, use the setting in the provisioning message. // FREEBIE * Send receipt messages silently Avoid generating phantom messages on ios // FREEBIE * Save recipients on the outgoing message models For accurate tracking and display of sent/delivered/read state, even if group membership changes later. // FREEBIE * Fix conversation type in profile key update handling // FREEBIE * Set recipients on synced sent messages * Render saved recipients in message detail if available For older messages, where we did not save the intended set of recipients at the time of sending, fall back to the current group membership. // FREEBIE * Record who has been successfully sent to // FREEBIE * Record who a message has been delivered to * Invert the not-clickable class * Fix readReceipt setting sync when linking * Render per recipient sent/delivered/read status In the message detail view for outgoing messages, render each recipient's individual sent/delivered/read status with respect to this message, as long as there are no errors associated with the recipient (ie, safety number changes, user not registered, etc...) since the error icon is displayed in that case. *Messages sent before this change may not have per-recipient status lists and will simply show no status icon. // FREEBIE * Add configuration sync request Send these requests in a one-off fashion when: 1. We have just setup from a chrome app import 2. We have just upgraded to read-receipt support // FREEBIE * Expose sendRequestConfigurationSyncMessage // FREEBIE * Fix handling of incoming delivery receipts - union with array FREEBIE
8 years ago
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());
6 years ago
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;
}
6 years ago
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);
6 years ago
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;