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.
		
		
		
		
		
			
		
			
				
	
	
		
			485 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			JavaScript
		
	
			
		
		
	
	
			485 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			JavaScript
		
	
| /* global textsecure, libsignal, window, btoa, _ */
 | |
| 
 | |
| /* eslint-disable more/no-then */
 | |
| 
 | |
| function OutgoingMessage(
 | |
|   server,
 | |
|   timestamp,
 | |
|   numbers,
 | |
|   message,
 | |
|   silent,
 | |
|   callback,
 | |
|   options = {}
 | |
| ) {
 | |
|   if (message instanceof textsecure.protobuf.DataMessage) {
 | |
|     const content = new textsecure.protobuf.Content();
 | |
|     content.dataMessage = message;
 | |
|     // eslint-disable-next-line no-param-reassign
 | |
|     message = content;
 | |
|   }
 | |
|   this.server = server;
 | |
|   this.timestamp = timestamp;
 | |
|   this.numbers = numbers;
 | |
|   this.message = message; // ContentMessage proto
 | |
|   this.callback = callback;
 | |
|   this.silent = silent;
 | |
| 
 | |
|   this.numbersCompleted = 0;
 | |
|   this.errors = [];
 | |
|   this.successfulNumbers = [];
 | |
|   this.failoverNumbers = [];
 | |
|   this.unidentifiedDeliveries = [];
 | |
| 
 | |
|   const { numberInfo, senderCertificate, online } = options || {};
 | |
|   this.numberInfo = numberInfo;
 | |
|   this.senderCertificate = senderCertificate;
 | |
|   this.online = online;
 | |
| }
 | |
| 
 | |
| OutgoingMessage.prototype = {
 | |
|   constructor: OutgoingMessage,
 | |
|   numberCompleted() {
 | |
|     this.numbersCompleted += 1;
 | |
|     if (this.numbersCompleted >= this.numbers.length) {
 | |
|       this.callback({
 | |
|         successfulNumbers: this.successfulNumbers,
 | |
|         failoverNumbers: this.failoverNumbers,
 | |
|         errors: this.errors,
 | |
|         unidentifiedDeliveries: this.unidentifiedDeliveries,
 | |
|       });
 | |
|     }
 | |
|   },
 | |
|   registerError(number, reason, error) {
 | |
|     if (!error || (error.name === 'HTTPError' && error.code !== 404)) {
 | |
|       // eslint-disable-next-line no-param-reassign
 | |
|       error = new textsecure.OutgoingMessageError(
 | |
|         number,
 | |
|         this.message.toArrayBuffer(),
 | |
|         this.timestamp,
 | |
|         error
 | |
|       );
 | |
|     }
 | |
| 
 | |
|     // eslint-disable-next-line no-param-reassign
 | |
|     error.number = number;
 | |
|     // eslint-disable-next-line no-param-reassign
 | |
|     error.reason = reason;
 | |
|     this.errors[this.errors.length] = error;
 | |
|     this.numberCompleted();
 | |
|   },
 | |
|   reloadDevicesAndSend(number, recurse) {
 | |
|     return () =>
 | |
|       textsecure.storage.protocol.getDeviceIds(number).then(deviceIds => {
 | |
|         if (deviceIds.length === 0) {
 | |
|           return this.registerError(
 | |
|             number,
 | |
|             'Got empty device list when loading device keys',
 | |
|             null
 | |
|           );
 | |
|         }
 | |
|         return this.doSendMessage(number, deviceIds, recurse);
 | |
|       });
 | |
|   },
 | |
| 
 | |
|   getKeysForNumber(number, updateDevices) {
 | |
|     const handleResult = response =>
 | |
|       Promise.all(
 | |
|         response.devices.map(device => {
 | |
|           // eslint-disable-next-line no-param-reassign
 | |
|           device.identityKey = response.identityKey;
 | |
|           if (
 | |
|             updateDevices === undefined ||
 | |
|             updateDevices.indexOf(device.deviceId) > -1
 | |
|           ) {
 | |
|             const address = new libsignal.SignalProtocolAddress(
 | |
|               number,
 | |
|               device.deviceId
 | |
|             );
 | |
|             const builder = new libsignal.SessionBuilder(
 | |
|               textsecure.storage.protocol,
 | |
|               address
 | |
|             );
 | |
|             if (device.registrationId === 0) {
 | |
|               window.log.info('device registrationId 0!');
 | |
|             }
 | |
|             return builder.processPreKey(device).catch(error => {
 | |
|               if (error.message === 'Identity key changed') {
 | |
|                 // eslint-disable-next-line no-param-reassign
 | |
|                 error.timestamp = this.timestamp;
 | |
|                 // eslint-disable-next-line no-param-reassign
 | |
|                 error.originalMessage = this.message.toArrayBuffer();
 | |
|                 // eslint-disable-next-line no-param-reassign
 | |
|                 error.identityKey = device.identityKey;
 | |
|               }
 | |
|               throw error;
 | |
|             });
 | |
|           }
 | |
| 
 | |
|           return null;
 | |
|         })
 | |
|       );
 | |
| 
 | |
|     const { numberInfo } = this;
 | |
|     const info = numberInfo && numberInfo[number] ? numberInfo[number] : {};
 | |
|     const { accessKey } = info || {};
 | |
| 
 | |
|     if (updateDevices === undefined) {
 | |
|       if (accessKey) {
 | |
|         return this.server
 | |
|           .getKeysForNumberUnauth(number, '*', { accessKey })
 | |
|           .catch(error => {
 | |
|             if (error.code === 401 || error.code === 403) {
 | |
|               if (this.failoverNumbers.indexOf(number) === -1) {
 | |
|                 this.failoverNumbers.push(number);
 | |
|               }
 | |
|               return this.server.getKeysForNumber(number, '*');
 | |
|             }
 | |
|             throw error;
 | |
|           })
 | |
|           .then(handleResult);
 | |
|       }
 | |
| 
 | |
|       return this.server.getKeysForNumber(number, '*').then(handleResult);
 | |
|     }
 | |
| 
 | |
|     let promise = Promise.resolve();
 | |
|     updateDevices.forEach(deviceId => {
 | |
|       promise = promise.then(() => {
 | |
|         let innerPromise;
 | |
| 
 | |
|         if (accessKey) {
 | |
|           innerPromise = this.server
 | |
|             .getKeysForNumberUnauth(number, deviceId, { accessKey })
 | |
|             .then(handleResult)
 | |
|             .catch(error => {
 | |
|               if (error.code === 401 || error.code === 403) {
 | |
|                 if (this.failoverNumbers.indexOf(number) === -1) {
 | |
|                   this.failoverNumbers.push(number);
 | |
|                 }
 | |
|                 return this.server
 | |
|                   .getKeysForNumber(number, deviceId)
 | |
|                   .then(handleResult);
 | |
|               }
 | |
|               throw error;
 | |
|             });
 | |
|         } else {
 | |
|           innerPromise = this.server
 | |
|             .getKeysForNumber(number, deviceId)
 | |
|             .then(handleResult);
 | |
|         }
 | |
| 
 | |
|         return innerPromise.catch(e => {
 | |
|           if (e.name === 'HTTPError' && e.code === 404) {
 | |
|             if (deviceId !== 1) {
 | |
|               return this.removeDeviceIdsForNumber(number, [deviceId]);
 | |
|             }
 | |
|             throw new textsecure.UnregisteredUserError(number, e);
 | |
|           } else {
 | |
|             throw e;
 | |
|           }
 | |
|         });
 | |
|       });
 | |
|     });
 | |
| 
 | |
|     return promise;
 | |
|   },
 | |
| 
 | |
|   transmitMessage(number, jsonData, timestamp, { accessKey } = {}) {
 | |
|     let promise;
 | |
| 
 | |
|     if (accessKey) {
 | |
|       promise = this.server.sendMessagesUnauth(
 | |
|         number,
 | |
|         jsonData,
 | |
|         timestamp,
 | |
|         this.silent,
 | |
|         this.online,
 | |
|         { accessKey }
 | |
|       );
 | |
|     } else {
 | |
|       promise = this.server.sendMessages(
 | |
|         number,
 | |
|         jsonData,
 | |
|         timestamp,
 | |
|         this.silent,
 | |
|         this.online
 | |
|       );
 | |
|     }
 | |
| 
 | |
|     return promise.catch(e => {
 | |
|       if (e.name === 'HTTPError' && (e.code !== 409 && e.code !== 410)) {
 | |
|         // 409 and 410 should bubble and be handled by doSendMessage
 | |
|         // 404 should throw UnregisteredUserError
 | |
|         // all other network errors can be retried later.
 | |
|         if (e.code === 404) {
 | |
|           throw new textsecure.UnregisteredUserError(number, e);
 | |
|         }
 | |
|         throw new textsecure.SendMessageNetworkError(
 | |
|           number,
 | |
|           jsonData,
 | |
|           e,
 | |
|           timestamp
 | |
|         );
 | |
|       }
 | |
|       throw e;
 | |
|     });
 | |
|   },
 | |
| 
 | |
|   getPaddedMessageLength(messageLength) {
 | |
|     const messageLengthWithTerminator = messageLength + 1;
 | |
|     let messagePartCount = Math.floor(messageLengthWithTerminator / 160);
 | |
| 
 | |
|     if (messageLengthWithTerminator % 160 !== 0) {
 | |
|       messagePartCount += 1;
 | |
|     }
 | |
| 
 | |
|     return messagePartCount * 160;
 | |
|   },
 | |
| 
 | |
|   getPlaintext() {
 | |
|     if (!this.plaintext) {
 | |
|       const messageBuffer = this.message.toArrayBuffer();
 | |
|       this.plaintext = new Uint8Array(
 | |
|         this.getPaddedMessageLength(messageBuffer.byteLength + 1) - 1
 | |
|       );
 | |
|       this.plaintext.set(new Uint8Array(messageBuffer));
 | |
|       this.plaintext[messageBuffer.byteLength] = 0x80;
 | |
|     }
 | |
|     return this.plaintext;
 | |
|   },
 | |
| 
 | |
|   doSendMessage(number, deviceIds, recurse) {
 | |
|     const ciphers = {};
 | |
|     const plaintext = this.getPlaintext();
 | |
| 
 | |
|     const { numberInfo, senderCertificate } = this;
 | |
|     const info = numberInfo && numberInfo[number] ? numberInfo[number] : {};
 | |
|     const { accessKey } = info || {};
 | |
| 
 | |
|     if (accessKey && !senderCertificate) {
 | |
|       return Promise.reject(
 | |
|         new Error(
 | |
|           'OutgoingMessage.doSendMessage: accessKey was provided, but senderCertificate was not'
 | |
|         )
 | |
|       );
 | |
|     }
 | |
| 
 | |
|     const sealedSender = Boolean(accessKey && senderCertificate);
 | |
| 
 | |
|     // We don't send to ourselves if unless sealedSender is enabled
 | |
|     const ourNumber = textsecure.storage.user.getNumber();
 | |
|     const ourDeviceId = textsecure.storage.user.getDeviceId();
 | |
|     if (number === ourNumber && !sealedSender) {
 | |
|       // eslint-disable-next-line no-param-reassign
 | |
|       deviceIds = _.reject(
 | |
|         deviceIds,
 | |
|         deviceId =>
 | |
|           // because we store our own device ID as a string at least sometimes
 | |
|           deviceId === ourDeviceId || deviceId === parseInt(ourDeviceId, 10)
 | |
|       );
 | |
|     }
 | |
| 
 | |
|     return Promise.all(
 | |
|       deviceIds.map(async deviceId => {
 | |
|         const address = new libsignal.SignalProtocolAddress(number, deviceId);
 | |
| 
 | |
|         const options = {};
 | |
| 
 | |
|         // No limit on message keys if we're communicating with our other devices
 | |
|         if (ourNumber === number) {
 | |
|           options.messageKeysLimit = false;
 | |
|         }
 | |
| 
 | |
|         if (sealedSender) {
 | |
|           const secretSessionCipher = new window.Signal.Metadata.SecretSessionCipher(
 | |
|             textsecure.storage.protocol
 | |
|           );
 | |
|           ciphers[address.getDeviceId()] = secretSessionCipher;
 | |
| 
 | |
|           const ciphertext = await secretSessionCipher.encrypt(
 | |
|             address,
 | |
|             senderCertificate,
 | |
|             plaintext
 | |
|           );
 | |
|           return {
 | |
|             type: textsecure.protobuf.Envelope.Type.UNIDENTIFIED_SENDER,
 | |
|             destinationDeviceId: address.getDeviceId(),
 | |
|             destinationRegistrationId: await secretSessionCipher.getRemoteRegistrationId(
 | |
|               address
 | |
|             ),
 | |
|             content: window.Signal.Crypto.arrayBufferToBase64(ciphertext),
 | |
|           };
 | |
|         }
 | |
| 
 | |
|         const sessionCipher = new libsignal.SessionCipher(
 | |
|           textsecure.storage.protocol,
 | |
|           address,
 | |
|           options
 | |
|         );
 | |
|         ciphers[address.getDeviceId()] = sessionCipher;
 | |
| 
 | |
|         const ciphertext = await sessionCipher.encrypt(plaintext);
 | |
|         return {
 | |
|           type: ciphertext.type,
 | |
|           destinationDeviceId: address.getDeviceId(),
 | |
|           destinationRegistrationId: ciphertext.registrationId,
 | |
|           content: btoa(ciphertext.body),
 | |
|         };
 | |
|       })
 | |
|     )
 | |
|       .then(jsonData => {
 | |
|         if (sealedSender) {
 | |
|           return this.transmitMessage(number, jsonData, this.timestamp, {
 | |
|             accessKey,
 | |
|           }).then(
 | |
|             () => {
 | |
|               this.unidentifiedDeliveries.push(number);
 | |
|               this.successfulNumbers.push(number);
 | |
|               this.numberCompleted();
 | |
|             },
 | |
|             error => {
 | |
|               if (error.code === 401 || error.code === 403) {
 | |
|                 if (this.failoverNumbers.indexOf(number) === -1) {
 | |
|                   this.failoverNumbers.push(number);
 | |
|                 }
 | |
|                 if (info) {
 | |
|                   info.accessKey = null;
 | |
|                 }
 | |
| 
 | |
|                 // Set final parameter to true to ensure we don't hit this codepath a
 | |
|                 //   second time.
 | |
|                 return this.doSendMessage(number, deviceIds, recurse, true);
 | |
|               }
 | |
| 
 | |
|               throw error;
 | |
|             }
 | |
|           );
 | |
|         }
 | |
| 
 | |
|         return this.transmitMessage(number, jsonData, this.timestamp).then(
 | |
|           () => {
 | |
|             this.successfulNumbers.push(number);
 | |
|             this.numberCompleted();
 | |
|           }
 | |
|         );
 | |
|       })
 | |
|       .catch(error => {
 | |
|         if (
 | |
|           error instanceof Error &&
 | |
|           error.name === 'HTTPError' &&
 | |
|           (error.code === 410 || error.code === 409)
 | |
|         ) {
 | |
|           if (!recurse)
 | |
|             return this.registerError(
 | |
|               number,
 | |
|               'Hit retry limit attempting to reload device list',
 | |
|               error
 | |
|             );
 | |
| 
 | |
|           let p;
 | |
|           if (error.code === 409) {
 | |
|             p = this.removeDeviceIdsForNumber(
 | |
|               number,
 | |
|               error.response.extraDevices
 | |
|             );
 | |
|           } else {
 | |
|             p = Promise.all(
 | |
|               error.response.staleDevices.map(deviceId =>
 | |
|                 ciphers[deviceId].closeOpenSessionForDevice(
 | |
|                   new libsignal.SignalProtocolAddress(number, deviceId)
 | |
|                 )
 | |
|               )
 | |
|             );
 | |
|           }
 | |
| 
 | |
|           return p.then(() => {
 | |
|             const resetDevices =
 | |
|               error.code === 410
 | |
|                 ? error.response.staleDevices
 | |
|                 : error.response.missingDevices;
 | |
|             return this.getKeysForNumber(number, resetDevices).then(
 | |
|               // We continue to retry as long as the error code was 409; the assumption is
 | |
|               //   that we'll request new device info and the next request will succeed.
 | |
|               this.reloadDevicesAndSend(number, error.code === 409)
 | |
|             );
 | |
|           });
 | |
|         } else if (error.message === 'Identity key changed') {
 | |
|           // eslint-disable-next-line no-param-reassign
 | |
|           error.timestamp = this.timestamp;
 | |
|           // eslint-disable-next-line no-param-reassign
 | |
|           error.originalMessage = this.message.toArrayBuffer();
 | |
|           window.log.error(
 | |
|             'Got "key changed" error from encrypt - no identityKey for application layer',
 | |
|             number,
 | |
|             deviceIds
 | |
|           );
 | |
|           throw error;
 | |
|         } else {
 | |
|           this.registerError(number, 'Failed to create or send message', error);
 | |
|         }
 | |
| 
 | |
|         return null;
 | |
|       });
 | |
|   },
 | |
| 
 | |
|   getStaleDeviceIdsForNumber(number) {
 | |
|     return textsecure.storage.protocol.getDeviceIds(number).then(deviceIds => {
 | |
|       if (deviceIds.length === 0) {
 | |
|         return [1];
 | |
|       }
 | |
|       const updateDevices = [];
 | |
|       return Promise.all(
 | |
|         deviceIds.map(deviceId => {
 | |
|           const address = new libsignal.SignalProtocolAddress(number, deviceId);
 | |
|           const sessionCipher = new libsignal.SessionCipher(
 | |
|             textsecure.storage.protocol,
 | |
|             address
 | |
|           );
 | |
|           return sessionCipher.hasOpenSession().then(hasSession => {
 | |
|             if (!hasSession) {
 | |
|               updateDevices.push(deviceId);
 | |
|             }
 | |
|           });
 | |
|         })
 | |
|       ).then(() => updateDevices);
 | |
|     });
 | |
|   },
 | |
| 
 | |
|   removeDeviceIdsForNumber(number, deviceIdsToRemove) {
 | |
|     let promise = Promise.resolve();
 | |
|     // eslint-disable-next-line no-restricted-syntax, guard-for-in
 | |
|     for (const j in deviceIdsToRemove) {
 | |
|       promise = promise.then(() => {
 | |
|         const encodedNumber = `${number}.${deviceIdsToRemove[j]}`;
 | |
|         return textsecure.storage.protocol.removeSession(encodedNumber);
 | |
|       });
 | |
|     }
 | |
|     return promise;
 | |
|   },
 | |
| 
 | |
|   async sendToNumber(number) {
 | |
|     try {
 | |
|       const updateDevices = await this.getStaleDeviceIdsForNumber(number);
 | |
|       await this.getKeysForNumber(number, updateDevices);
 | |
|       await this.reloadDevicesAndSend(number, true)();
 | |
|     } catch (error) {
 | |
|       if (error.message === 'Identity key changed') {
 | |
|         // eslint-disable-next-line no-param-reassign
 | |
|         const newError = new textsecure.OutgoingIdentityKeyError(
 | |
|           number,
 | |
|           error.originalMessage,
 | |
|           error.timestamp,
 | |
|           error.identityKey
 | |
|         );
 | |
|         this.registerError(number, 'Identity key changed', newError);
 | |
|       } else {
 | |
|         this.registerError(
 | |
|           number,
 | |
|           `Failed to retrieve new device keys for number ${number}`,
 | |
|           error
 | |
|         );
 | |
|       }
 | |
|     }
 | |
|   },
 | |
| };
 |