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.
		
		
		
		
		
			
		
			
				
	
	
		
			1142 lines
		
	
	
		
			36 KiB
		
	
	
	
		
			JavaScript
		
	
			
		
		
	
	
			1142 lines
		
	
	
		
			36 KiB
		
	
	
	
		
			JavaScript
		
	
| /* global window: false */
 | |
| /* global textsecure: false */
 | |
| /* global TextSecureServer: false */
 | |
| /* global libsignal: false */
 | |
| /* global WebSocketResource: false */
 | |
| /* global WebSocket: false */
 | |
| /* global Event: false */
 | |
| /* global dcodeIO: false */
 | |
| /* global _: false */
 | |
| /* global ContactBuffer: false */
 | |
| /* global GroupBuffer: false */
 | |
| 
 | |
| /* eslint-disable more/no-then */
 | |
| 
 | |
| function MessageReceiver(url, username, password, signalingKey, options = {}) {
 | |
|   this.count = 0;
 | |
| 
 | |
|   this.url = url;
 | |
|   this.signalingKey = signalingKey;
 | |
|   this.username = username;
 | |
|   this.password = password;
 | |
|   this.server = new TextSecureServer(url, username, password);
 | |
| 
 | |
|   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.prototype = new textsecure.EventTarget();
 | |
| MessageReceiver.prototype.extend({
 | |
|   constructor: MessageReceiver,
 | |
|   connect() {
 | |
|     if (this.calledClose) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     this.hasConnected = true;
 | |
| 
 | |
|     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() {
 | |
|     console.log('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() {
 | |
|     console.log('websocket open');
 | |
|   },
 | |
|   onerror() {
 | |
|     console.log('websocket error');
 | |
|   },
 | |
|   dispatchAndWait(event) {
 | |
|     return Promise.all(this.dispatchEvent(event));
 | |
|   },
 | |
|   onclose(ev) {
 | |
|     console.log(
 | |
|       'websocket closed',
 | |
|       ev.code,
 | |
|       ev.reason || '',
 | |
|       'calledClose:',
 | |
|       this.calledClose
 | |
|     );
 | |
| 
 | |
|     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 || [];
 | |
|     // 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') {
 | |
|       console.log('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 = 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');
 | |
|         }
 | |
| 
 | |
|         return this.addToCache(envelope, plaintext).then(
 | |
|           () => {
 | |
|             request.respond(200, 'OK');
 | |
|             this.queueEnvelope(envelope);
 | |
|           },
 | |
|           error => {
 | |
|             console.log(
 | |
|               'handleRequest error trying to add message to cache:',
 | |
|               error && error.stack ? error.stack : error
 | |
|             );
 | |
|           }
 | |
|         );
 | |
|       })
 | |
|       .catch(e => {
 | |
|         request.respond(500, 'Bad encrypted websocket message');
 | |
|         console.log(
 | |
|           '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 = () => {
 | |
|       console.log("MessageReceiver: emitting 'empty' event");
 | |
|       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(() => {
 | |
|         console.log('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);
 | |
|   },
 | |
|   queueAllCached() {
 | |
|     return this.getAllFromCache().then(items => {
 | |
|       for (let i = 0, max = items.length; i < max; i += 1) {
 | |
|         this.queueCached(items[i]);
 | |
|       }
 | |
|     });
 | |
|   },
 | |
|   queueCached(item) {
 | |
|     try {
 | |
|       let envelopePlaintext = item.envelope;
 | |
| 
 | |
|       // Up until 0.42.6 we stored envelope and decrypted as strings in IndexedDB,
 | |
|       //   so we need to be ready for them.
 | |
|       if (typeof envelopePlaintext === 'string') {
 | |
|         envelopePlaintext = this.stringToArrayBuffer(envelopePlaintext);
 | |
|       }
 | |
|       const envelope = textsecure.protobuf.Envelope.decode(envelopePlaintext);
 | |
| 
 | |
|       const { decrypted } = item;
 | |
|       if (decrypted) {
 | |
|         let payloadPlaintext = decrypted;
 | |
|         if (typeof payloadPlaintext === 'string') {
 | |
|           payloadPlaintext = this.stringToArrayBuffer(payloadPlaintext);
 | |
|         }
 | |
|         this.queueDecryptedEnvelope(envelope, payloadPlaintext);
 | |
|       } else {
 | |
|         this.queueEnvelope(envelope);
 | |
|       }
 | |
|     } catch (error) {
 | |
|       console.log('queueCached error handling item', item.id);
 | |
|     }
 | |
|   },
 | |
|   getEnvelopeId(envelope) {
 | |
|     return `${envelope.source}.${
 | |
|       envelope.sourceDevice
 | |
|     } ${envelope.timestamp.toNumber()}`;
 | |
|   },
 | |
|   stringToArrayBuffer(string) {
 | |
|     // eslint-disable-next-line new-cap
 | |
|     return new dcodeIO.ByteBuffer.wrap(string, 'binary').toArrayBuffer();
 | |
|   },
 | |
|   getAllFromCache() {
 | |
|     console.log('getAllFromCache');
 | |
|     return textsecure.storage.unprocessed.getAll().then(items => {
 | |
|       console.log('getAllFromCache loaded', items.length, 'saved envelopes');
 | |
| 
 | |
|       return Promise.all(
 | |
|         _.map(items, item => {
 | |
|           const attempts = 1 + (item.attempts || 0);
 | |
|           if (attempts >= 5) {
 | |
|             console.log('getAllFromCache final attempt for envelope', item.id);
 | |
|             return textsecure.storage.unprocessed.remove(item.id);
 | |
|           }
 | |
|           return textsecure.storage.unprocessed.update(item.id, { attempts });
 | |
|         })
 | |
|       ).then(
 | |
|         () => items,
 | |
|         error => {
 | |
|           console.log(
 | |
|             'getAllFromCache error updating items after load:',
 | |
|             error && error.stack ? error.stack : error
 | |
|           );
 | |
|           return items;
 | |
|         }
 | |
|       );
 | |
|     });
 | |
|   },
 | |
|   addToCache(envelope, plaintext) {
 | |
|     const id = this.getEnvelopeId(envelope);
 | |
|     const data = {
 | |
|       id,
 | |
|       envelope: plaintext,
 | |
|       timestamp: Date.now(),
 | |
|       attempts: 1,
 | |
|     };
 | |
|     return textsecure.storage.unprocessed.add(data);
 | |
|   },
 | |
|   updateCache(envelope, plaintext) {
 | |
|     const id = this.getEnvelopeId(envelope);
 | |
|     const data = {
 | |
|       decrypted: plaintext,
 | |
|     };
 | |
|     return textsecure.storage.unprocessed.update(id, data);
 | |
|   },
 | |
|   removeFromCache(envelope) {
 | |
|     const id = this.getEnvelopeId(envelope);
 | |
|     return textsecure.storage.unprocessed.remove(id);
 | |
|   },
 | |
|   queueDecryptedEnvelope(envelope, plaintext) {
 | |
|     const id = this.getEnvelopeId(envelope);
 | |
|     console.log('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 => {
 | |
|       console.log(
 | |
|         'queueDecryptedEnvelope error handling envelope',
 | |
|         id,
 | |
|         ':',
 | |
|         error && error.stack ? error.stack : error
 | |
|       );
 | |
|     });
 | |
|   },
 | |
|   queueEnvelope(envelope) {
 | |
|     const id = this.getEnvelopeId(envelope);
 | |
|     console.log('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 => {
 | |
|       console.log(
 | |
|         '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);
 | |
|     } else if (envelope.legacyMessage) {
 | |
|       return this.handleLegacyMessage(envelope);
 | |
|     }
 | |
|     this.removeFromCache(envelope);
 | |
|     throw new Error('Received message with no content and no legacyMessage');
 | |
|   },
 | |
|   getStatus() {
 | |
|     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;
 | |
|   },
 | |
|   decrypt(envelope, ciphertext) {
 | |
|     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
 | |
|     );
 | |
| 
 | |
|     switch (envelope.type) {
 | |
|       case textsecure.protobuf.Envelope.Type.CIPHERTEXT:
 | |
|         console.log('message from', this.getEnvelopeId(envelope));
 | |
|         promise = sessionCipher
 | |
|           .decryptWhisperMessage(ciphertext)
 | |
|           .then(this.unpad);
 | |
|         break;
 | |
|       case textsecure.protobuf.Envelope.Type.PREKEY_BUNDLE:
 | |
|         console.log('prekey message from', this.getEnvelopeId(envelope));
 | |
|         promise = this.decryptPreKeyWhisperMessage(
 | |
|           ciphertext,
 | |
|           sessionCipher,
 | |
|           address
 | |
|         );
 | |
|         break;
 | |
|       default:
 | |
|         promise = Promise.reject(new Error('Unknown message type'));
 | |
|     }
 | |
| 
 | |
|     return promise
 | |
|       .then(plaintext =>
 | |
|         this.updateCache(envelope, plaintext).then(
 | |
|           () => plaintext,
 | |
|           error => {
 | |
|             console.log(
 | |
|               'decrypt failed to save decrypted message contents to cache:',
 | |
|               error && error.stack ? error.stack : error
 | |
|             );
 | |
|             return plaintext;
 | |
|           }
 | |
|         )
 | |
|       )
 | |
|       .catch(error => {
 | |
|         let errorToThrow = error;
 | |
| 
 | |
|         if (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
 | |
|           );
 | |
|         }
 | |
|         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,
 | |
|     destination,
 | |
|     timestamp,
 | |
|     msg,
 | |
|     expirationStartTimestamp
 | |
|   ) {
 | |
|     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 ev = new Event('sent');
 | |
|         ev.confirm = this.removeFromCache.bind(this, envelope);
 | |
|         ev.data = {
 | |
|           destination,
 | |
|           timestamp: timestamp.toNumber(),
 | |
|           device: envelope.sourceDevice,
 | |
|           message,
 | |
|         };
 | |
|         if (expirationStartTimestamp) {
 | |
|           ev.data.expirationStartTimestamp = expirationStartTimestamp.toNumber();
 | |
|         }
 | |
|         return this.dispatchAndWait(ev);
 | |
|       })
 | |
|     );
 | |
|   },
 | |
|   handleDataMessage(envelope, msg) {
 | |
|     console.log('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 ev = new Event('message');
 | |
|         ev.confirm = this.removeFromCache.bind(this, envelope);
 | |
|         ev.data = {
 | |
|           source: envelope.source,
 | |
|           sourceDevice: envelope.sourceDevice,
 | |
|           timestamp: envelope.timestamp.toNumber(),
 | |
|           receivedAt: envelope.receivedAt,
 | |
|           message,
 | |
|         };
 | |
|         return this.dispatchAndWait(ev);
 | |
|       })
 | |
|     );
 | |
|   },
 | |
|   handleLegacyMessage(envelope) {
 | |
|     return this.decrypt(envelope, envelope.legacyMessage).then(plaintext =>
 | |
|       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 =>
 | |
|       this.innerHandleContentMessage(envelope, plaintext)
 | |
|     );
 | |
|   },
 | |
|   innerHandleContentMessage(envelope, plaintext) {
 | |
|     const content = textsecure.protobuf.Content.decode(plaintext);
 | |
|     if (content.syncMessage) {
 | |
|       return this.handleSyncMessage(envelope, content.syncMessage);
 | |
|     } else if (content.dataMessage) {
 | |
|       return this.handleDataMessage(envelope, content.dataMessage);
 | |
|     } else if (content.nullMessage) {
 | |
|       return this.handleNullMessage(envelope, content.nullMessage);
 | |
|     } else if (content.callMessage) {
 | |
|       return this.handleCallMessage(envelope, content.callMessage);
 | |
|     } else if (content.receiptMessage) {
 | |
|       return this.handleReceiptMessage(envelope, content.receiptMessage);
 | |
|     }
 | |
|     this.removeFromCache(envelope);
 | |
|     throw new Error('Unsupported content message');
 | |
|   },
 | |
|   handleCallMessage(envelope) {
 | |
|     console.log('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);
 | |
|   },
 | |
|   handleNullMessage(envelope) {
 | |
|     console.log('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;
 | |
| 
 | |
|       console.log(
 | |
|         'sent message to',
 | |
|         to,
 | |
|         sentMessage.timestamp.toNumber(),
 | |
|         'from',
 | |
|         this.getEnvelopeId(envelope)
 | |
|       );
 | |
|       return this.handleSentMessage(
 | |
|         envelope,
 | |
|         sentMessage.destination,
 | |
|         sentMessage.timestamp,
 | |
|         sentMessage.message,
 | |
|         sentMessage.expirationStartTimestamp
 | |
|       );
 | |
|     } 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) {
 | |
|       console.log('Got SyncMessage Request');
 | |
|       return this.removeFromCache(envelope);
 | |
|     } else if (syncMessage.read && syncMessage.read.length) {
 | |
|       console.log('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) {
 | |
|     const ev = new Event('configuration');
 | |
|     ev.confirm = this.removeFromCache.bind(this, envelope);
 | |
|     ev.configuration = {
 | |
|       readReceipts: configuration.readReceipts,
 | |
|     };
 | |
|     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) {
 | |
|     console.log('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.confirm = this.removeFromCache.bind(this, envelope);
 | |
|         ev.contactDetails = contactDetails;
 | |
|         results.push(this.dispatchAndWait(ev));
 | |
| 
 | |
|         contactDetails = contactBuffer.next();
 | |
|       }
 | |
| 
 | |
|       const ev = new Event('contactsync');
 | |
|       ev.confirm = this.removeFromCache.bind(this, envelope);
 | |
|       results.push(this.dispatchAndWait(ev));
 | |
| 
 | |
|       return Promise.all(results);
 | |
|     });
 | |
|   },
 | |
|   handleGroups(envelope, groups) {
 | |
|     console.log('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 => {
 | |
|             console.log('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) {
 | |
|     console.log('Setting these numbers as blocked:', blocked.numbers);
 | |
|     textsecure.storage.put('blocked', blocked.numbers);
 | |
|   },
 | |
|   isBlocked(number) {
 | |
|     return textsecure.storage.get('blocked', []).indexOf(number) >= 0;
 | |
|   },
 | |
|   handleAttachment(attachment) {
 | |
|     // 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);
 | |
|   },
 | |
|   validateRetryContentMessage(content) {
 | |
|     // Today this is only called for incoming identity key errors, so it can't be a sync
 | |
|     //   message.
 | |
|     if (content.syncMessage) {
 | |
|       return false;
 | |
|     }
 | |
| 
 | |
|     // We want at least one field set, but not more than one
 | |
|     let count = 0;
 | |
|     count += content.dataMessage ? 1 : 0;
 | |
|     count += content.callMessage ? 1 : 0;
 | |
|     count += content.nullMessage ? 1 : 0;
 | |
|     if (count !== 1) {
 | |
|       return false;
 | |
|     }
 | |
| 
 | |
|     // It's most likely that dataMessage will be populated, so we look at it in detail
 | |
|     const data = content.dataMessage;
 | |
|     if (
 | |
|       data &&
 | |
|       !data.attachments.length &&
 | |
|       !data.body &&
 | |
|       !data.expireTimer &&
 | |
|       !data.flags &&
 | |
|       !data.group
 | |
|     ) {
 | |
|       return false;
 | |
|     }
 | |
| 
 | |
|     return true;
 | |
|   },
 | |
|   tryMessageAgain(from, ciphertext, message) {
 | |
|     const address = libsignal.SignalProtocolAddress.fromString(from);
 | |
|     const sentAt = message.sent_at || Date.now();
 | |
|     const receivedAt = message.received_at || Date.now();
 | |
| 
 | |
|     const ourNumber = textsecure.storage.user.getNumber();
 | |
|     const number = address.getName();
 | |
|     const device = address.getDeviceId();
 | |
|     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
 | |
|     );
 | |
|     console.log('retrying prekey whisper message');
 | |
|     return this.decryptPreKeyWhisperMessage(
 | |
|       ciphertext,
 | |
|       sessionCipher,
 | |
|       address
 | |
|     ).then(plaintext => {
 | |
|       const envelope = {
 | |
|         source: number,
 | |
|         sourceDevice: device,
 | |
|         receivedAt,
 | |
|         timestamp: {
 | |
|           toNumber() {
 | |
|             return sentAt;
 | |
|           },
 | |
|         },
 | |
|       };
 | |
| 
 | |
|       // Before June, all incoming messages were still DataMessage:
 | |
|       //   - iOS: Michael Kirk says that they were sending Legacy messages until June
 | |
|       //   - Desktop: https://github.com/signalapp/Signal-Desktop/commit/e8548879db405d9bcd78b82a456ad8d655592c0f
 | |
|       //   - Android: https://github.com/signalapp/libsignal-service-java/commit/61a75d023fba950ff9b4c75a249d1a3408e12958
 | |
|       //
 | |
|       // var d = new Date('2017-06-01T07:00:00.000Z');
 | |
|       // d.getTime();
 | |
|       const startOfJune = 1496300400000;
 | |
|       if (sentAt < startOfJune) {
 | |
|         return this.innerHandleLegacyMessage(envelope, plaintext);
 | |
|       }
 | |
| 
 | |
|       // This is ugly. But we don't know what kind of proto we need to decode...
 | |
|       try {
 | |
|         // Simply decoding as a Content message may throw
 | |
|         const content = textsecure.protobuf.Content.decode(plaintext);
 | |
| 
 | |
|         // But it might also result in an invalid object, so we try to detect that
 | |
|         if (this.validateRetryContentMessage(content)) {
 | |
|           return this.innerHandleContentMessage(envelope, plaintext);
 | |
|         }
 | |
|       } catch (e) {
 | |
|         return this.innerHandleLegacyMessage(envelope, plaintext);
 | |
|       }
 | |
| 
 | |
|       return this.innerHandleLegacyMessage(envelope, plaintext);
 | |
|     });
 | |
|   },
 | |
|   async handleEndSession(number) {
 | |
|     console.log('got end session');
 | |
|     const deviceIds = await textsecure.storage.protocol.getDeviceIds(number);
 | |
| 
 | |
|     return Promise.all(
 | |
|       deviceIds.map(deviceId => {
 | |
|         const address = new libsignal.SignalProtocolAddress(number, deviceId);
 | |
|         const sessionCipher = new libsignal.SessionCipher(
 | |
|           textsecure.storage.protocol,
 | |
|           address
 | |
|         );
 | |
| 
 | |
|         console.log('deleting sessions for', address.toString());
 | |
|         return sessionCipher.deleteAllSessionsForDevice();
 | |
|       })
 | |
|     );
 | |
|   },
 | |
|   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];
 | |
|               console.log('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...
 | |
|             console.log(
 | |
|               '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');
 | |
|           }
 | |
|         })
 | |
|       );
 | |
|     }
 | |
| 
 | |
|     for (let i = 0, max = decrypted.attachments.length; i < max; i += 1) {
 | |
|       const attachment = decrypted.attachments[i];
 | |
|       promises.push(this.handleAttachment(attachment));
 | |
|     }
 | |
| 
 | |
|     if (
 | |
|       decrypted.contact &&
 | |
|       decrypted.contact.avatar &&
 | |
|       decrypted.contact.avatar.avatar
 | |
|     ) {
 | |
|       promises.push(this.handleAttachment(decrypted.contact.avatar.avatar));
 | |
|     }
 | |
| 
 | |
|     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 => {
 | |
|               console.log(
 | |
|                 '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(
 | |
|   url,
 | |
|   username,
 | |
|   password,
 | |
|   signalingKey,
 | |
|   options
 | |
| ) {
 | |
|   const messageReceiver = new MessageReceiver(
 | |
|     url,
 | |
|     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);
 | |
|   messageReceiver.connect();
 | |
| 
 | |
|   textsecure.replay.registerFunction(
 | |
|     messageReceiver.tryMessageAgain.bind(messageReceiver),
 | |
|     textsecure.replay.Type.INIT_SESSION
 | |
|   );
 | |
| };
 | |
| 
 | |
| textsecure.MessageReceiver.prototype = {
 | |
|   constructor: textsecure.MessageReceiver,
 | |
| };
 |