From cf3e9716ed72baa7aaaa2de0131cd97cf1cef3d0 Mon Sep 17 00:00:00 2001 From: Mikunj Date: Tue, 20 Nov 2018 11:13:01 +1100 Subject: [PATCH 01/17] Update new conditions for friend accepted and key exchange complete in conversations model. --- js/models/conversations.js | 123 +++++++++++------------------- libtextsecure/outgoing_message.js | 2 +- 2 files changed, 46 insertions(+), 79 deletions(-) diff --git a/js/models/conversations.js b/js/models/conversations.js index 2b9501275..a607ea9c2 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -52,6 +52,26 @@ 'blue_grey', ]; + /** + * A few key things that need to be known in this is the difference + * between isFriend() and isKeyExhangeCompleted(). + * + * `isFriend` returns whether we have accepted the other user as a friend. + * - This is implicitly checked by whether we have a session + * or we have the preKeyBundle of the user. + * + * `isKeyExchangeCompleted` return whether we know for certain + * that both of our preKeyBundles have been exhanged. + * - This will be set when we receive a valid CIPHER message from the other user. + * * Valid meaning we can decypher the message using the preKeys provided + * or the keys we have stored. + * + * `isFriend` will determine whether we should send a FRIEND_REQUEST message. + * + * `isKeyExhangeCompleted` will determine whether we keep + * sending preKeyBundle to the other user. + */ + Whisper.Conversation = Backbone.Model.extend({ storeName: 'conversations', defaults() { @@ -59,7 +79,6 @@ unreadCount: 0, verified: textsecure.storage.protocol.VerifiedStatus.DEFAULT, keyExchangeCompleted: false, - friendRequestStatus: { allowSending: true, unlockTimestamp: null }, }; }, @@ -444,48 +463,30 @@ return this.get('keyExchangeCompleted') || false; }, - getFriendRequestStatus() { - return this.get('friendRequestStatus'); - }, - waitingForFriendRequestApproval() { - const friendRequestStatus = this.getFriendRequestStatus(); - if (!friendRequestStatus) { - return false; - } - return !friendRequestStatus.allowSending; - }, - setFriendRequestTimer() { - const friendRequestStatus = this.getFriendRequestStatus(); - if (friendRequestStatus) { - if (!friendRequestStatus.allowSending) { - const delay = Math.max( - friendRequestStatus.unlockTimestamp - Date.now(), - 0 - ); - setTimeout(() => { - this.onFriendRequestTimedOut(); - }, delay); - } - } - }, - async onFriendRequestAccepted({ updateUnread }) { - // Make sure we don't keep incrementing the unread count - const unreadCount = !updateUnread || this.isKeyExchangeCompleted() - ? {} - : { unreadCount: this.get('unreadCount') + 1 }; - this.set({ - friendRequestStatus: null, - keyExchangeCompleted: true, - ...unreadCount, - }); - + async setKeyExchangeCompleted(value) { + this.set({ keyExchangeCompleted: value }); await window.Signal.Data.updateConversation(this.id, this.attributes, { Conversation: Whisper.Conversation, }); + }, + async isFriend() { + // We are a friend IF: + // - We have the preKey bundle of the user OR + // - We have a session with the user + const preKeys = await window.Signal.Data.getContactPreKeyByIdentityKey(this.id); + const session = await window.Signal.Data.getSessionsByNumber(this.id); + return !!(preKeys || session); + }, + // Update any pending friend requests for the current user + async updateFriendRequestUI() { // Enable the text inputs early this.updateTextInputState(); + // We only update our friend requests if we have the user as a friend + const isFriend = await this.isFriend(); + if (!isFriend) return; + // Update any pending outgoing messages const pending = await this.getPendingFriendRequests('outgoing'); await Promise.all( @@ -500,50 +501,14 @@ }) ); + // Update our local state await this.updatePendingFriendRequests(); + // Send the notification this.notifyFriendRequest(this.id, 'accepted') }, - async onFriendRequestTimedOut() { - this.updateTextInputState(); - - const friendRequestStatus = this.getFriendRequestStatus(); - if (friendRequestStatus) { - friendRequestStatus.allowSending = true; - this.set({ friendRequestStatus }); - - await window.Signal.Data.updateConversation(this.id, this.attributes, { - Conversation: Whisper.Conversation, - }); - } - }, async onFriendRequestSent() { - // Don't bother setting the friend request if we have already exchanged keys - if (this.isKeyExchangeCompleted()) return; - - const friendRequestLockDuration = 72; // hours - - let friendRequestStatus = this.getFriendRequestStatus(); - if (!friendRequestStatus) { - friendRequestStatus = {}; - } - - friendRequestStatus.allowSending = false; - const delayMs = 60 * 60 * 1000 * friendRequestLockDuration; - friendRequestStatus.unlockTimestamp = Date.now() + delayMs; - - // Update the text input state - this.updateTextInputState(); - - this.set({ friendRequestStatus }); - - await window.Signal.Data.updateConversation(this.id, this.attributes, { - Conversation: Whisper.Conversation, - }); - - setTimeout(() => { - this.onFriendRequestTimedOut(); - }, delayMs); + return this.updateFriendRequestUI(); }, isUnverified() { if (this.isPrivate()) { @@ -1012,8 +977,9 @@ let messageWithSchema = null; - // If we have exchanged keys then let the user send the message normally - if (this.isKeyExchangeCompleted()) { + // If we are a friend then let the user send the message normally + const isFriend = await this.isFriend(); + if (isFriend) { messageWithSchema = await upgradeMessageSchema({ type: 'outgoing', body, @@ -1143,7 +1109,8 @@ }, async updateTextInputState() { // Check if we need to disable the text field - if (!this.isKeyExchangeCompleted()) { + const isFriend = await this.isFriend(); + if (isFriend) { // Check if we have an incoming friend request // Or any successful outgoing ones const incoming = await this.getPendingFriendRequests('incoming'); diff --git a/libtextsecure/outgoing_message.js b/libtextsecure/outgoing_message.js index 699e6214b..9abce0997 100644 --- a/libtextsecure/outgoing_message.js +++ b/libtextsecure/outgoing_message.js @@ -468,7 +468,7 @@ OutgoingMessage.prototype = { .then(this.reloadDevicesAndSend(number, true)) .catch(error => { if (this.fallBackEncryption && conversation) { - conversation.onFriendRequestTimedOut(); + conversation.updateFriendRequestUI(); } if (error.message === 'Identity key changed') { // eslint-disable-next-line no-param-reassign From 1264630649f691de33ccc2b08c5f671450444b08 Mon Sep 17 00:00:00 2001 From: Mikunj Date: Tue, 20 Nov 2018 14:15:33 +1100 Subject: [PATCH 02/17] Attach preKeyBundle outside of the message content. --- libtextsecure/outgoing_message.js | 83 +++++++++++++++++-------------- protos/SignalService.proto | 2 +- 2 files changed, 47 insertions(+), 38 deletions(-) diff --git a/libtextsecure/outgoing_message.js b/libtextsecure/outgoing_message.js index 9abce0997..b83a55b5e 100644 --- a/libtextsecure/outgoing_message.js +++ b/libtextsecure/outgoing_message.js @@ -202,15 +202,19 @@ OutgoingMessage.prototype = { return messagePartCount * 160; }, + convertMessageToText(message) { + const messageBuffer = message.toArrayBuffer(); + const plaintext = new Uint8Array( + this.getPaddedMessageLength(messageBuffer.byteLength + 1) - 1 + ); + plaintext.set(new Uint8Array(messageBuffer)); + plaintext[messageBuffer.byteLength] = 0x80; + return plaintext; + }, 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; + this.plaintext = this.convertMessageToText(this.message); } return this.plaintext; }, @@ -275,6 +279,18 @@ OutgoingMessage.prototype = { const address = new libsignal.SignalProtocolAddress(number, deviceId); const ourKey = textsecure.storage.user.getNumber(); const options = {}; + const fallBackEncryption = new libloki.FallBackSessionCipher(address); + + // Check if we need to attach the preKeys + let preKeys = {}; + if (this.attachPrekeys) { + // Encrypt them with the fallback + const preKeyBundleMessage = await libloki.getPreKeyBundleForNumber(number); + const textBundle = this.convertMessageToText(preKeyBundleMessage); + const encryptedBundle = await fallBackEncryption.encrypt(textBundle); + preKeys = { preKeyBundleMessage: encryptedBundle.body }; + window.log.info('attaching prekeys to outgoing message'); + } // No limit on message keys if we're communicating with our other devices if (ourKey === number) { @@ -283,7 +299,7 @@ OutgoingMessage.prototype = { let sessionCipher; if (this.fallBackEncryption) { - sessionCipher = new libloki.FallBackSessionCipher(address); + sessionCipher = fallBackEncryption; } else { sessionCipher = new libsignal.SessionCipher( textsecure.storage.protocol, @@ -292,26 +308,26 @@ OutgoingMessage.prototype = { ); } ciphers[address.getDeviceId()] = sessionCipher; - return sessionCipher - .encrypt(plaintext) - .then(ciphertext => { - if (!this.fallBackEncryption) - // eslint-disable-next-line no-param-reassign - ciphertext.body = new Uint8Array( - dcodeIO.ByteBuffer.wrap( - ciphertext.body, - 'binary' - ).toArrayBuffer() - ); - return ciphertext; - }) - .then(ciphertext => ({ - type: ciphertext.type, - ourKey, - sourceDevice: 1, - destinationRegistrationId: ciphertext.registrationId, - content: ciphertext.body, - })); + + // Encrypt our plain text + const ciphertext = await sessionCipher.encrypt(plaintext); + if (!this.fallBackEncryption) { + // eslint-disable-next-line no-param-reassign + ciphertext.body = new Uint8Array( + dcodeIO.ByteBuffer.wrap( + ciphertext.body, + 'binary' + ).toArrayBuffer() + ); + } + return { + type: ciphertext.type, // FallBackSessionCipher sets this to FRIEND_REQUEST + ourKey, + sourceDevice: 1, + destinationRegistrationId: ciphertext.registrationId, + content: ciphertext.body, + ...preKeys, + }; }) ) .then(async outgoingObjects => { @@ -441,14 +457,14 @@ OutgoingMessage.prototype = { return this.getStaleDeviceIdsForNumber(number).then(updateDevices => this.getKeysForNumber(number, updateDevices) .then(async keysFound => { - let attachPrekeys = false; + this.attachPrekeys = false; if (!keysFound) { log.info('Fallback encryption enabled'); this.fallBackEncryption = true; - attachPrekeys = true; + this.attachPrekeys = true; } else if (conversation) { try { - attachPrekeys = !conversation.isKeyExchangeCompleted(); + this.attachPrekeys = !conversation.isKeyExchangeCompleted(); } catch (e) { // do nothing } @@ -457,13 +473,6 @@ OutgoingMessage.prototype = { if (this.fallBackEncryption && conversation) { conversation.onFriendRequestSent(); } - - if (attachPrekeys) { - log.info('attaching prekeys to outgoing message'); - this.message.preKeyBundleMessage = await libloki.getPreKeyBundleForNumber( - number - ); - } }) .then(this.reloadDevicesAndSend(number, true)) .catch(error => { diff --git a/protos/SignalService.proto b/protos/SignalService.proto index e76ff9218..5e2b905de 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -24,6 +24,7 @@ message Envelope { optional bytes content = 8; // Contains an encrypted Content optional string serverGuid = 9; optional uint64 serverTimestamp = 10; + optional PreKeyBundleMessage preKeyBundleMessage = 101; } @@ -33,7 +34,6 @@ message Content { optional CallMessage callMessage = 3; optional NullMessage nullMessage = 4; optional ReceiptMessage receiptMessage = 5; - optional PreKeyBundleMessage preKeyBundleMessage = 6; } message PreKeyBundleMessage { From 6c5c95d9564e7c239fe55192025112c79de9cf1f Mon Sep 17 00:00:00 2001 From: Mikunj Date: Tue, 20 Nov 2018 14:44:48 +1100 Subject: [PATCH 03/17] Added preKey decryption. --- libtextsecure/message_receiver.js | 35 +++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/libtextsecure/message_receiver.js b/libtextsecure/message_receiver.js index ad83b9b8c..109b66ac9 100644 --- a/libtextsecure/message_receiver.js +++ b/libtextsecure/message_receiver.js @@ -431,6 +431,7 @@ MessageReceiver.prototype.extend({ envelope.sourceDevice = envelope.sourceDevice || item.sourceDevice; envelope.serverTimestamp = envelope.serverTimestamp || item.serverTimestamp; + envelope.preKeyBundleMessage = envelope.preKeyBundleMessage || item.preKeyBundleMessage; const { decrypted } = item; if (decrypted) { @@ -447,6 +448,13 @@ MessageReceiver.prototype.extend({ 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); @@ -672,7 +680,7 @@ MessageReceiver.prototype.extend({ return plaintext; }, - decrypt(envelope, ciphertext) { + async decrypt(envelope, ciphertext) { const { serverTrustRoot } = this; let promise; @@ -699,6 +707,28 @@ MessageReceiver.prototype.extend({ textsecure.storage.protocol ); + const fallBackSessionCipher = new libloki.FallBackSessionCipher( + address + ); + + // Check if we have preKey bundles to decrypt + if (envelope.preKeyBundleMessage) { + const decryptedText = await fallBackSessionCipher.decrypt(envelope.preKeyBundleMessage); + + // Convert the decryptedText to an array buffer if we have a string + if (typeof decryptedText === 'string') { + // eslint-disable-next-line no-param-reassign + envelope.preKeyBundleMessage = await MessageReceiver.stringToArrayBuffer( + decryptedText + ); + } else { + // eslint-disable-next-line no-param-reassign + envelope.preKeyBundleMessage = decryptedText; + } + + // TODO: Do we save the preKeys here? + } + const me = { number: ourNumber, deviceId: parseInt(textsecure.storage.user.getDeviceId(), 10), @@ -712,9 +742,6 @@ MessageReceiver.prototype.extend({ break; case textsecure.protobuf.Envelope.Type.FRIEND_REQUEST: { window.log.info('friend-request message from ', envelope.source); - const fallBackSessionCipher = new libloki.FallBackSessionCipher( - address - ); promise = fallBackSessionCipher.decrypt(ciphertext.toArrayBuffer()) .then(this.unpad); break; From 4ebdfab633e1dd9e114a2f9793a6010f885fb854 Mon Sep 17 00:00:00 2001 From: Mikunj Date: Tue, 20 Nov 2018 14:56:30 +1100 Subject: [PATCH 04/17] Update handling of new message logic. --- js/models/conversations.js | 5 ++- libtextsecure/message_receiver.js | 71 +++++++++++++------------------ 2 files changed, 34 insertions(+), 42 deletions(-) diff --git a/js/models/conversations.js b/js/models/conversations.js index a607ea9c2..ced2a574f 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -54,7 +54,7 @@ /** * A few key things that need to be known in this is the difference - * between isFriend() and isKeyExhangeCompleted(). + * between isFriend() and isKeyExchangeCompleted(). * * `isFriend` returns whether we have accepted the other user as a friend. * - This is implicitly checked by whether we have a session @@ -464,6 +464,9 @@ return this.get('keyExchangeCompleted') || false; }, async setKeyExchangeCompleted(value) { + // Only update the value if it's different + if (this.get('keyExchangeCompleted') === value) return; + this.set({ keyExchangeCompleted: value }); await window.Signal.Data.updateConversation(this.id, this.attributes, { Conversation: Whisper.Conversation, diff --git a/libtextsecure/message_receiver.js b/libtextsecure/message_receiver.js index 109b66ac9..9f015214f 100644 --- a/libtextsecure/message_receiver.js +++ b/libtextsecure/message_receiver.js @@ -726,7 +726,15 @@ MessageReceiver.prototype.extend({ envelope.preKeyBundleMessage = decryptedText; } - // TODO: Do we save the preKeys here? + // Save the preKey bundle if this is not a friend request. + // We don't automatically save on a friend request because + // we only want to save the preKeys when we click the accept button. + if (envelope.type !== textsecure.protobuf.Envelope.Type.FRIEND_REQUEST) { + await this.handlePreKeyBundleMessage( + envelope.source, + envelope.preKeyBundleMessage + ); + } } const me = { @@ -1008,10 +1016,8 @@ MessageReceiver.prototype.extend({ conversation.updateTextInputState(); } - // Send our own prekeys as a response + // If we accepted an incoming friend request then save the preKeyBundle if (message.direction === 'incoming' && message.friendStatus === 'accepted') { - libloki.sendEmptyMessageWithPreKeys(pubKey); - // Register the preKeys used for communication if (message.preKeyBundle) { await this.handlePreKeyBundleMessage( @@ -1020,60 +1026,43 @@ MessageReceiver.prototype.extend({ ); } - await conversation.onFriendRequestAccepted({ updateUnread: false }); + // Send a reply back + libloki.sendEmptyMessageWithPreKeys(pubKey); + + if (conversation) { + await conversation.updateFriendRequestUI(); + } } window.log.info(`Friend request for ${pubKey} was ${message.friendStatus}`, message); }, async innerHandleContentMessage(envelope, plaintext) { const content = textsecure.protobuf.Content.decode(plaintext); - if (envelope.type === textsecure.protobuf.Envelope.Type.FRIEND_REQUEST) { - let conversation; - try { - conversation = window.ConversationController.get(envelope.source); - } catch (e) { - throw new Error('Error getting conversation for message.') - } + let conversation; + try { + conversation = window.ConversationController.get(envelope.source); + } catch (e) { + window.log.info('Error getting conversation: ', envelope.source); + } + if (envelope.type === textsecure.protobuf.Envelope.Type.FRIEND_REQUEST) { // only prompt friend request if there is no conversation yet if (!conversation) { this.promptUserToAcceptFriendRequest( envelope, content.dataMessage.body, - content.preKeyBundleMessage + envelope.preKeyBundleMessage ); - } else { - const keyExchangeComplete = conversation.isKeyExchangeCompleted(); - - // Check here if we received preKeys from the other user - // We are certain that other user accepted the friend request IF: - // - The message has a preKeyBundleMessage - // - We have an outgoing friend request that is pending - // The second check is crucial because it makes sure we don't save the preKeys of - // the incoming friend request (which is saved only when we press accept) - if (!keyExchangeComplete && content.preKeyBundleMessage) { - // Check for any outgoing friend requests - const pending = await conversation.getPendingFriendRequests('outgoing'); - const successful = pending.filter(p => !p.hasErrors()); - - // Save the key only if we have an outgoing friend request - const savePreKey = (successful.length > 0); - - // Save the pre key - if (savePreKey) { - await this.handlePreKeyBundleMessage( - envelope.source, - this.decodePreKeyBundleMessage(content.preKeyBundleMessage) - ); - - // Update the conversation - await conversation.onFriendRequestAccepted({ updateUnread: true }); - } - } } // Exit early since the friend request reply will be a regular empty message return null; + } else if (envelope.type === textsecure.protobuf.Envelope.Type.CIPHERTEXT) { + // If we get a cipher text and we are friends then we can mark keys as exchanged + if (conversation && conversation.isFriend()) { + await conversation.setKeyExchangeCompleted(true); + await conversation.updateFriendRequestUI(); + } } if (content.syncMessage) { From 110387508fbbcccca394d0c4f193147c0697dda1 Mon Sep 17 00:00:00 2001 From: Mikunj Date: Tue, 20 Nov 2018 15:26:13 +1100 Subject: [PATCH 05/17] UI fixes. --- js/background.js | 16 ++++++++++++++-- js/models/conversations.js | 28 ++++++++++++++-------------- js/views/conversation_view.js | 8 +++----- libtextsecure/message_receiver.js | 28 ++++++++++++++++------------ 4 files changed, 47 insertions(+), 33 deletions(-) diff --git a/js/background.js b/js/background.js index cfd4e1366..179affa15 100644 --- a/js/background.js +++ b/js/background.js @@ -1259,7 +1259,7 @@ async function initIncomingMessage(data, options = {}) { const { isError } = options; - const message = new Whisper.Message({ + let messageData = { source: data.source, sourceDevice: data.sourceDevice, sent_at: data.timestamp, @@ -1268,7 +1268,19 @@ unidentifiedDeliveryReceived: data.unidentifiedDeliveryReceived, type: 'incoming', unread: 1, - }); + }; + + if (data.type === 'friend-request') { + messageData = { + ...messageData, + type: 'friend-request', + friendStatus: 'pending', + preKeyBundle: data.preKeyBundle || null, + direction: 'incoming', + } + } + + const message = new Whisper.Message(messageData); // If we don't return early here, we can get into infinite error loops. So, no // delivery receipts for sealed sender errors. diff --git a/js/models/conversations.js b/js/models/conversations.js index ced2a574f..b4e56afac 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -144,10 +144,6 @@ this.on('expiration-change', this.updateAndMerge); this.on('expired', this.onExpired); - setTimeout(() => { - this.setFriendRequestTimer(); - }, 0); - const sealedSender = this.get('sealedSender'); if (sealedSender === undefined) { this.set({ sealedSender: SEALED_SENDER.UNKNOWN }); @@ -472,14 +468,23 @@ Conversation: Whisper.Conversation, }); }, + async waitingForFriendRequestApproval() { + // Check if we have an incoming friend request + // Or any successful outgoing ones + const incoming = await this.getPendingFriendRequests('incoming'); + const outgoing = await this.getPendingFriendRequests('outgoing'); + const successfulOutgoing = outgoing.filter(o => !o.hasErrors()); + + return (incoming.length > 0 || successfulOutgoing.length > 0); + }, async isFriend() { // We are a friend IF: // - We have the preKey bundle of the user OR // - We have a session with the user const preKeys = await window.Signal.Data.getContactPreKeyByIdentityKey(this.id); - const session = await window.Signal.Data.getSessionsByNumber(this.id); + // const session = await window.Signal.Data.getSessionsByNumber(this.id); - return !!(preKeys || session); + return !!preKeys; }, // Update any pending friend requests for the current user async updateFriendRequestUI() { @@ -1114,14 +1119,9 @@ // Check if we need to disable the text field const isFriend = await this.isFriend(); if (isFriend) { - // Check if we have an incoming friend request - // Or any successful outgoing ones - const incoming = await this.getPendingFriendRequests('incoming'); - const outgoing = await this.getPendingFriendRequests('outgoing'); - const successfulOutgoing = outgoing.filter(o => !o.hasErrors()); - - // Disable the input - if (incoming.length > 0 || successfulOutgoing.length > 0) { + // Disable the input if we're waiting for friend request approval + const waiting = await this.waitingForFriendRequestApproval(); + if (waiting) { this.trigger('disable:input', true); this.trigger('change:placeholder', 'disabled'); return; diff --git a/js/views/conversation_view.js b/js/views/conversation_view.js index 35099e78b..5512d7d39 100644 --- a/js/views/conversation_view.js +++ b/js/views/conversation_view.js @@ -70,14 +70,10 @@ template: $('#conversation').html(), render_attributes() { let sendMessagePlaceholder = 'sendMessageFriendRequest'; - const sendDisabled = this.model.waitingForFriendRequestApproval(); - if (sendDisabled) { - sendMessagePlaceholder = 'sendMessageDisabled'; - } else if (this.model.getFriendRequestStatus() === null) { + if (this.model.isFriend()) { sendMessagePlaceholder = 'sendMessage'; } return { - 'disable-inputs': sendDisabled, 'send-message': i18n(sendMessagePlaceholder), 'android-length-warning': i18n('androidMessageLengthWarning'), }; @@ -240,6 +236,8 @@ this.$('.send-message').blur(this.unfocusBottomBar.bind(this)); this.$emojiPanelContainer = this.$('.emoji-panel-container'); + + this.model.updateFriendRequestUI(); }, events: { diff --git a/libtextsecure/message_receiver.js b/libtextsecure/message_receiver.js index 9f015214f..1d3da3f16 100644 --- a/libtextsecure/message_receiver.js +++ b/libtextsecure/message_receiver.js @@ -929,7 +929,10 @@ MessageReceiver.prototype.extend({ }) ); }, - handleDataMessage(envelope, msg) { + async handleFriendRequestMessage(envelope, msg) { + return this.handleDataMessage(envelope, msg, 'friend-request'); + }, + handleDataMessage(envelope, msg, type = 'data') { window.log.info('data message from', this.getEnvelopeId(envelope)); let p = Promise.resolve(); // eslint-disable-next-line no-bitwise @@ -946,6 +949,13 @@ MessageReceiver.prototype.extend({ message.group.type === textsecure.protobuf.GroupContext.Type.QUIT ); + if (type === 'friend-request' && isMe) { + window.log.info( + 'refusing to add a friend request to ourselves' + ); + throw new Error('Cannot add a friend request for ourselves!') + } + if (groupId && isBlocked && !(isMe && isLeavingGroup)) { window.log.warn( `Message ${this.getEnvelopeId( @@ -955,15 +965,19 @@ MessageReceiver.prototype.extend({ return this.removeFromCache(envelope); } + const preKeyBundle = envelope.preKeyBundleMessage && this.decodePreKeyBundleMessage(envelope.preKeyBundleMessage); + const ev = new Event('message'); ev.confirm = this.removeFromCache.bind(this, envelope); ev.data = { + type, source: envelope.source, sourceDevice: envelope.sourceDevice, timestamp: envelope.timestamp.toNumber(), receivedAt: envelope.receivedAt, unidentifiedDeliveryReceived: envelope.unidentifiedDeliveryReceived, message, + preKeyBundle: preKeyBundle || null, }; return this.dispatchAndWait(ev); }) @@ -1046,17 +1060,7 @@ MessageReceiver.prototype.extend({ } if (envelope.type === textsecure.protobuf.Envelope.Type.FRIEND_REQUEST) { - // only prompt friend request if there is no conversation yet - if (!conversation) { - this.promptUserToAcceptFriendRequest( - envelope, - content.dataMessage.body, - envelope.preKeyBundleMessage - ); - } - - // Exit early since the friend request reply will be a regular empty message - return null; + return this.handleFriendRequestMessage(envelope, content.dataMessage); } else if (envelope.type === textsecure.protobuf.Envelope.Type.CIPHERTEXT) { // If we get a cipher text and we are friends then we can mark keys as exchanged if (conversation && conversation.isFriend()) { From b21a7197f7ea474f7c6d5814bd58fed2465345ac Mon Sep 17 00:00:00 2001 From: Mikunj Date: Tue, 20 Nov 2018 16:20:57 +1100 Subject: [PATCH 06/17] Fix preKeyBundle not sending. --- libtextsecure/outgoing_message.js | 4 ++++ protos/SignalService.proto | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/libtextsecure/outgoing_message.js b/libtextsecure/outgoing_message.js index b83a55b5e..2687ebea2 100644 --- a/libtextsecure/outgoing_message.js +++ b/libtextsecure/outgoing_message.js @@ -219,12 +219,16 @@ OutgoingMessage.prototype = { return this.plaintext; }, async wrapInWebsocketMessage(outgoingObject) { + const preKeyEnvelope = outgoingObject.preKeyBundleMessage ? { + preKeyBundleMessage: outgoingObject.preKeyBundleMessage, + } : {}; const messageEnvelope = new textsecure.protobuf.Envelope({ type: outgoingObject.type, source: outgoingObject.ourKey, sourceDevice: outgoingObject.sourceDevice, timestamp: this.timestamp, content: outgoingObject.content, + ...preKeyEnvelope, }); const requestMessage = new textsecure.protobuf.WebSocketRequestMessage({ id: new Uint8Array(libsignal.crypto.getRandomBytes(1))[0], // random ID for now diff --git a/protos/SignalService.proto b/protos/SignalService.proto index 5e2b905de..ecf9743d1 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -24,7 +24,7 @@ message Envelope { optional bytes content = 8; // Contains an encrypted Content optional string serverGuid = 9; optional uint64 serverTimestamp = 10; - optional PreKeyBundleMessage preKeyBundleMessage = 101; + optional bytes preKeyBundleMessage = 101; } From 75219966a7982a9511cac0b04730a0f850e26742 Mon Sep 17 00:00:00 2001 From: Mikunj Date: Wed, 21 Nov 2018 11:02:00 +1100 Subject: [PATCH 07/17] Fixed preKeyBundle decryption. --- libloki/proof-of-work.js | 2 +- libtextsecure/message_receiver.js | 25 +++++++++++++------------ 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/libloki/proof-of-work.js b/libloki/proof-of-work.js index 3adab32b2..2b4579d32 100644 --- a/libloki/proof-of-work.js +++ b/libloki/proof-of-work.js @@ -4,7 +4,7 @@ const { BigInteger } = require('jsbn'); const NONCE_LEN = 8; // Modify this value for difficulty scaling -const NONCE_TRIALS = 1000; +const NONCE_TRIALS = 10; // Increment Uint8Array nonce by 1 with carrying function incrementNonce(nonce) { diff --git a/libtextsecure/message_receiver.js b/libtextsecure/message_receiver.js index 1d3da3f16..f7d14f071 100644 --- a/libtextsecure/message_receiver.js +++ b/libtextsecure/message_receiver.js @@ -541,7 +541,7 @@ MessageReceiver.prototype.extend({ return textsecure.storage.unprocessed.add(data); }, async updateCache(envelope, plaintext) { - const { id } = envelope; + const { id, preKeyBundleMessage } = envelope; const item = await textsecure.storage.unprocessed.get(id); if (!item) { window.log.error( @@ -556,6 +556,7 @@ MessageReceiver.prototype.extend({ sourceDevice: envelope.sourceDevice, serverTimestamp: envelope.serverTimestamp, decrypted: await MessageReceiver.arrayBufferToStringBase64(plaintext), + preKeyBundleMessage: await MessageReceiver.arrayBufferToStringBase64(preKeyBundleMessage), }); } else { item.set({ @@ -563,6 +564,7 @@ MessageReceiver.prototype.extend({ sourceDevice: envelope.sourceDevice, serverTimestamp: envelope.serverTimestamp, decrypted: await MessageReceiver.arrayBufferToString(plaintext), + preKeyBundleMessage: await MessageReceiver.arrayBufferToStringBase64(preKeyBundleMessage), }); } @@ -713,18 +715,11 @@ MessageReceiver.prototype.extend({ // Check if we have preKey bundles to decrypt if (envelope.preKeyBundleMessage) { - const decryptedText = await fallBackSessionCipher.decrypt(envelope.preKeyBundleMessage); + const decryptedText = await fallBackSessionCipher.decrypt(envelope.preKeyBundleMessage.toArrayBuffer()); + const unpadded = await this.unpad(decryptedText); - // Convert the decryptedText to an array buffer if we have a string - if (typeof decryptedText === 'string') { - // eslint-disable-next-line no-param-reassign - envelope.preKeyBundleMessage = await MessageReceiver.stringToArrayBuffer( - decryptedText - ); - } else { - // eslint-disable-next-line no-param-reassign - envelope.preKeyBundleMessage = decryptedText; - } + // eslint-disable-next-line no-param-reassign + envelope.preKeyBundleMessage = unpadded; // Save the preKey bundle if this is not a friend request. // We don't automatically save on a friend request because @@ -1051,6 +1046,12 @@ MessageReceiver.prototype.extend({ }, async innerHandleContentMessage(envelope, plaintext) { const content = textsecure.protobuf.Content.decode(plaintext); + const preKeyBundleMessage = envelope.preKeyBundleMessage && textsecure.protobuf.PreKeyBundleMessage.decode(envelope.preKeyBundleMessage); + + // Set the decoded preKeyMessage + if (preKeyBundleMessage) { + envelope.preKeyBundleMessage = preKeyBundleMessage; + } let conversation; try { From dba6a36e830306ae80aca522e79fc90d03cbbd0c Mon Sep 17 00:00:00 2001 From: Mikunj Date: Wed, 21 Nov 2018 11:48:10 +1100 Subject: [PATCH 08/17] removed old unused code. --- js/background.js | 8 +-- js/models/conversations.js | 99 +++++-------------------------- js/models/messages.js | 11 +++- js/views/app_view.js | 10 ---- libloki/proof-of-work.js | 2 +- libtextsecure/message_receiver.js | 33 +++-------- 6 files changed, 33 insertions(+), 130 deletions(-) diff --git a/js/background.js b/js/background.js index 179affa15..b29be0b31 100644 --- a/js/background.js +++ b/js/background.js @@ -570,12 +570,6 @@ } }); - Whisper.events.on('showFriendRequest', friendRequest => { - if (appView) { - appView.showFriendRequest(friendRequest); - } - }); - Whisper.events.on('calculatingPoW', ({ pubKey, timestamp }) => { try { const conversation = ConversationController.get(pubKey); @@ -1268,6 +1262,7 @@ unidentifiedDeliveryReceived: data.unidentifiedDeliveryReceived, type: 'incoming', unread: 1, + preKeyBundle: data.preKeyBundle || null, }; if (data.type === 'friend-request') { @@ -1275,7 +1270,6 @@ ...messageData, type: 'friend-request', friendStatus: 'pending', - preKeyBundle: data.preKeyBundle || null, direction: 'incoming', } } diff --git a/js/models/conversations.js b/js/models/conversations.js index b4e56afac..005905f78 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -668,91 +668,6 @@ existing.trigger('destroy'); } }, - // This will add a message which will allow the user to reply to a friend request - async addFriendRequest(body, options = {}) { - const _options = { - friendStatus: 'pending', - direction: 'incoming', - preKeyBundle: null, - timestamp: null, - source: null, - sourceDevice: null, - received_at: null, - ...options, - }; - - if (this.isMe()) { - window.log.info( - 'refusing to send friend request to ourselves' - ); - return; - } - - const timestamp = _options.timestamp || this.get('timestamp') || Date.now(); - - window.log.info( - 'adding friend request for', - this.ourNumber, - this.idForLogging(), - timestamp - ); - - this.lastMessageStatus = 'sending'; - - this.set({ - active_at: Date.now(), - timestamp: Date.now(), - unreadCount: this.get('unreadCount') + 1, - }); - - await window.Signal.Data.updateConversation(this.id, this.attributes, { - Conversation: Whisper.Conversation, - }); - - // If we need to add new incoming friend requests - // Then we need to make sure we remove any pending requests that we may have - // This is to ensure that one user cannot spam us with multiple friend requests - if (_options.direction === 'incoming') { - const requests = await this.getPendingFriendRequests('incoming'); - - // Delete the old message if it's pending - await Promise.all(requests.map(request => this._removeMessage(request.id))); - // Trigger an update if we removed messages - if (requests.length > 0) - this.trigger('change'); - } - - // Add the new message - // eslint-disable-next-line camelcase - const received_at = _options.received_at || Date.now(); - const message = { - conversationId: this.id, - type: 'friend-request', - sent_at: timestamp, - received_at, - unread: 1, - from: this.id, - to: this.ourNumber, - friendStatus: _options.friendStatus, - direction: _options.direction, - body, - preKeyBundle: _options.preKeyBundle, - source: _options.source, - sourceDevice: _options.sourceDevice, - }; - - const id = await window.Signal.Data.saveMessage(message, { - Message: Whisper.Message, - }); - - const whisperMessage = new Whisper.Message({ - ...message, - id, - }); - - this.trigger('newmessage', whisperMessage); - this.notify(whisperMessage); - }, async addVerifiedChange(verifiedChangeId, verified, providedOptions) { const options = providedOptions || {}; _.defaults(options, { local: true }); @@ -1333,6 +1248,20 @@ this.changed = {}; this.set(lastMessageUpdate); + // If we need to add new incoming friend requests + // Then we need to make sure we remove any pending requests that we may have + // This is to ensure that one user cannot spam us with multiple friend requests + if (lastMessage.isFriendRequest() && lastMessage.direction === 'incoming') { + const requests = await this.getPendingFriendRequests('incoming'); + + // Delete the old message if it's pending + await Promise.all(requests.map(request => this._removeMessage(request.id))); + + // Trigger an update if we removed messages + hasChanged = hasChanged || (requests.length > 0); + } + + if (this.hasChanged()) { await window.Signal.Data.updateConversation(this.id, this.attributes, { Conversation: Whisper.Conversation, diff --git a/js/models/messages.js b/js/models/messages.js index d6d1fd71e..1d5ee7386 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -1227,7 +1227,16 @@ hasVisualMediaAttachments: dataMessage.hasVisualMediaAttachments, quote: dataMessage.quote, schemaVersion: dataMessage.schemaVersion, + preKeyBundle: dataMessage.preKeyBundle || null, }); + + if (type === 'friend-request') { + message.set({ + friendStatus: dataMessage.friendStatus, + direction: dataMessage.direction, + }); + } + if (type === 'outgoing') { const receipts = Whisper.DeliveryReceipts.forMessage( conversation, @@ -1291,7 +1300,7 @@ ); } } - if (type === 'incoming') { + if (type === 'incoming' || type === 'friend-request') { const readSync = Whisper.ReadSyncs.forMessage(message); if (readSync) { if ( diff --git a/js/views/app_view.js b/js/views/app_view.js index bcf325768..1db6314e3 100644 --- a/js/views/app_view.js +++ b/js/views/app_view.js @@ -178,15 +178,5 @@ }); } }, - async showFriendRequest({ pubKey, message, preKeyBundle, options }) { - const controller = window.ConversationController; - const conversation = await controller.getOrCreateAndWait(pubKey, 'private'); - if (conversation) { - conversation.addFriendRequest(message, { - preKeyBundle: preKeyBundle || null, - ...options, - }); - } - }, }); })(); diff --git a/libloki/proof-of-work.js b/libloki/proof-of-work.js index 2b4579d32..3adab32b2 100644 --- a/libloki/proof-of-work.js +++ b/libloki/proof-of-work.js @@ -4,7 +4,7 @@ const { BigInteger } = require('jsbn'); const NONCE_LEN = 8; // Modify this value for difficulty scaling -const NONCE_TRIALS = 10; +const NONCE_TRIALS = 1000; // Increment Uint8Array nonce by 1 with carrying function incrementNonce(nonce) { diff --git a/libtextsecure/message_receiver.js b/libtextsecure/message_receiver.js index f7d14f071..bec5faed1 100644 --- a/libtextsecure/message_receiver.js +++ b/libtextsecure/message_receiver.js @@ -541,7 +541,7 @@ MessageReceiver.prototype.extend({ return textsecure.storage.unprocessed.add(data); }, async updateCache(envelope, plaintext) { - const { id, preKeyBundleMessage } = envelope; + const { id } = envelope; const item = await textsecure.storage.unprocessed.get(id); if (!item) { window.log.error( @@ -556,7 +556,6 @@ MessageReceiver.prototype.extend({ sourceDevice: envelope.sourceDevice, serverTimestamp: envelope.serverTimestamp, decrypted: await MessageReceiver.arrayBufferToStringBase64(plaintext), - preKeyBundleMessage: await MessageReceiver.arrayBufferToStringBase64(preKeyBundleMessage), }); } else { item.set({ @@ -564,7 +563,6 @@ MessageReceiver.prototype.extend({ sourceDevice: envelope.sourceDevice, serverTimestamp: envelope.serverTimestamp, decrypted: await MessageReceiver.arrayBufferToString(plaintext), - preKeyBundleMessage: await MessageReceiver.arrayBufferToStringBase64(preKeyBundleMessage), }); } @@ -717,9 +715,11 @@ MessageReceiver.prototype.extend({ if (envelope.preKeyBundleMessage) { const decryptedText = await fallBackSessionCipher.decrypt(envelope.preKeyBundleMessage.toArrayBuffer()); const unpadded = await this.unpad(decryptedText); + const decodedProto = textsecure.protobuf.PreKeyBundleMessage.decode(unpadded); + const decodedBundle = this.decodePreKeyBundleMessage(decodedProto); // eslint-disable-next-line no-param-reassign - envelope.preKeyBundleMessage = unpadded; + envelope.preKeyBundleMessage = decodedBundle; // Save the preKey bundle if this is not a friend request. // We don't automatically save on a friend request because @@ -960,8 +960,6 @@ MessageReceiver.prototype.extend({ return this.removeFromCache(envelope); } - const preKeyBundle = envelope.preKeyBundleMessage && this.decodePreKeyBundleMessage(envelope.preKeyBundleMessage); - const ev = new Event('message'); ev.confirm = this.removeFromCache.bind(this, envelope); ev.data = { @@ -972,7 +970,7 @@ MessageReceiver.prototype.extend({ receivedAt: envelope.receivedAt, unidentifiedDeliveryReceived: envelope.unidentifiedDeliveryReceived, message, - preKeyBundle: preKeyBundle || null, + preKeyBundle: envelope.preKeyBundleMessage || null, }; return this.dispatchAndWait(ev); }) @@ -1000,19 +998,6 @@ MessageReceiver.prototype.extend({ return this.innerHandleContentMessage(envelope, plaintext); }); }, - promptUserToAcceptFriendRequest(envelope, message, preKeyBundleMessage) { - window.Whisper.events.trigger('showFriendRequest', { - pubKey: envelope.source, - message, - preKeyBundle: this.decodePreKeyBundleMessage(preKeyBundleMessage), - options: { - source: envelope.source, - sourceDevice: envelope.sourceDevice, - timestamp: envelope.timestamp.toNumber(), - receivedAt: envelope.receivedAt, - }, - }); - }, // A handler function for when a friend request is accepted or declined async onFriendRequestUpdate(pubKey, message) { if (!message || !message.direction || !message.friendStatus) return; @@ -1046,12 +1031,6 @@ MessageReceiver.prototype.extend({ }, async innerHandleContentMessage(envelope, plaintext) { const content = textsecure.protobuf.Content.decode(plaintext); - const preKeyBundleMessage = envelope.preKeyBundleMessage && textsecure.protobuf.PreKeyBundleMessage.decode(envelope.preKeyBundleMessage); - - // Set the decoded preKeyMessage - if (preKeyBundleMessage) { - envelope.preKeyBundleMessage = preKeyBundleMessage; - } let conversation; try { @@ -1300,6 +1279,8 @@ MessageReceiver.prototype.extend({ }; }, async handlePreKeyBundleMessage(pubKey, preKeyBundleMessage) { + if (!preKeyBundleMessage) return null; + const { preKeyId, signedKeyId, From 7d8719f2508089a624d17dc8784705d6c8dfa00d Mon Sep 17 00:00:00 2001 From: Mikunj Date: Wed, 21 Nov 2018 14:16:50 +1100 Subject: [PATCH 09/17] Fix incoming friend request messages being deleted. Set pending outgoing friend requests to declined if we receive an incoming friend request. Fix text input not blocking. --- js/models/conversations.js | 64 ++++++++++++++++++++++++++--------- js/models/messages.js | 8 ----- js/views/conversation_view.js | 8 ++++- 3 files changed, 55 insertions(+), 25 deletions(-) diff --git a/js/models/conversations.js b/js/models/conversations.js index 005905f78..cbdd250ce 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -79,6 +79,7 @@ unreadCount: 0, verified: textsecure.storage.protocol.VerifiedStatus.DEFAULT, keyExchangeCompleted: false, + blockInput: false, }; }, @@ -134,7 +135,7 @@ this.updateLastMessage ); - this.on('newmessage', this.updateLastMessage); + this.on('newmessage', this.onNewMessage); this.on('change:profileKey', this.onChangeProfileKey); // Listening for out-of-band data updates @@ -1030,22 +1031,32 @@ return true; }); }, + async updateBlockInput(blockInput) { + if (this.get('blockInput') === blockInput) return; + this.set({ blockInput }); + await window.Signal.Data.updateConversation(this.id, this.attributes, { + Conversation: Whisper.Conversation, + }); + }, async updateTextInputState() { // Check if we need to disable the text field const isFriend = await this.isFriend(); - if (isFriend) { + if (!isFriend) { // Disable the input if we're waiting for friend request approval const waiting = await this.waitingForFriendRequestApproval(); if (waiting) { + await this.updateBlockInput(true); this.trigger('disable:input', true); this.trigger('change:placeholder', 'disabled'); return; } // Tell the user to introduce themselves + await this.updateBlockInput(false); this.trigger('disable:input', false); this.trigger('change:placeholder', 'friend-request'); return; } + await this.updateBlockInput(false); this.trigger('disable:input', false); this.trigger('change:placeholder', 'chat'); }, @@ -1199,6 +1210,41 @@ }, }; }, + async onNewMessage(message) { + if (message.get('type') === 'friend-request' && message.get('direction') === 'incoming') { + // We need to make sure we remove any pending requests that we may have + // This is to ensure that one user cannot spam us with multiple friend requests. + const incoming = await this.getPendingFriendRequests('incoming'); + + // Delete the old messages if it's pending + await Promise.all( + incoming + .filter(i => i.id !== message.id) + .map(request => this._removeMessage(request.id)) + ); + + // We also need to update any outgoing pending requests and set them to denied. + // when we get an incoming friend request. + const outgoing = await this.getPendingFriendRequests('outgoing'); + await Promise.all( + outgoing.map(async request => { + if (request.hasErrors()) return; + + request.set({ friendStatus: 'declined' }); + await window.Signal.Data.saveMessage(request.attributes, { + Message: Whisper.Message, + }); + this.trigger('updateMessage', request); + }) + ); + + // Trigger an update if we removed or updated messages + if (outgoing.length > 0 || incoming.length > 0) + this.trigger('change'); + } + + return this.updateLastMessage(); + }, async updateLastMessage() { if (!this.id) { return; @@ -1248,20 +1294,6 @@ this.changed = {}; this.set(lastMessageUpdate); - // If we need to add new incoming friend requests - // Then we need to make sure we remove any pending requests that we may have - // This is to ensure that one user cannot spam us with multiple friend requests - if (lastMessage.isFriendRequest() && lastMessage.direction === 'incoming') { - const requests = await this.getPendingFriendRequests('incoming'); - - // Delete the old message if it's pending - await Promise.all(requests.map(request => this._removeMessage(request.id))); - - // Trigger an update if we removed messages - hasChanged = hasChanged || (requests.length > 0); - } - - if (this.hasChanged()) { await window.Signal.Data.updateConversation(this.id, this.attributes, { Conversation: Whisper.Conversation, diff --git a/js/models/messages.js b/js/models/messages.js index 1d5ee7386..b7483f1fe 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -1227,16 +1227,8 @@ hasVisualMediaAttachments: dataMessage.hasVisualMediaAttachments, quote: dataMessage.quote, schemaVersion: dataMessage.schemaVersion, - preKeyBundle: dataMessage.preKeyBundle || null, }); - if (type === 'friend-request') { - message.set({ - friendStatus: dataMessage.friendStatus, - direction: dataMessage.direction, - }); - } - if (type === 'outgoing') { const receipts = Whisper.DeliveryReceipts.forMessage( conversation, diff --git a/js/views/conversation_view.js b/js/views/conversation_view.js index 5512d7d39..b71480657 100644 --- a/js/views/conversation_view.js +++ b/js/views/conversation_view.js @@ -70,10 +70,14 @@ template: $('#conversation').html(), render_attributes() { let sendMessagePlaceholder = 'sendMessageFriendRequest'; - if (this.model.isFriend()) { + const sendDisabled = this.model.get('blockInput'); + if (sendDisabled) { + sendMessagePlaceholder = 'sendMessageDisabled'; + } else if (this.model.isFriend()) { sendMessagePlaceholder = 'sendMessage'; } return { + 'disable-inputs': sendDisabled, 'send-message': i18n(sendMessagePlaceholder), 'android-length-warning': i18n('androidMessageLengthWarning'), }; @@ -140,6 +144,8 @@ this.render(); + this.model.updateTextInputState(); + this.loadingScreen = new Whisper.ConversationLoadingScreen(); this.loadingScreen.render(); this.loadingScreen.$el.prependTo(this.$('.discussion-container')); From db1145c0ce8f5e725803917806a4eff73ca535b3 Mon Sep 17 00:00:00 2001 From: Mikunj Date: Wed, 21 Nov 2018 15:37:19 +1100 Subject: [PATCH 10/17] Fix handling empty content. --- libtextsecure/message_receiver.js | 13 ++++++++++--- protos/SignalService.proto | 2 +- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/libtextsecure/message_receiver.js b/libtextsecure/message_receiver.js index bec5faed1..2ddd81fe1 100644 --- a/libtextsecure/message_receiver.js +++ b/libtextsecure/message_receiver.js @@ -1006,8 +1006,8 @@ MessageReceiver.prototype.extend({ const conversation = window.ConversationController.get(pubKey); if (conversation) { // Update the conversation friend request indicator - conversation.updatePendingFriendRequests(); - conversation.updateTextInputState(); + await conversation.updatePendingFriendRequests(); + await conversation.updateTextInputState(); } // If we accepted an incoming friend request then save the preKeyBundle @@ -1041,7 +1041,12 @@ MessageReceiver.prototype.extend({ if (envelope.type === textsecure.protobuf.Envelope.Type.FRIEND_REQUEST) { return this.handleFriendRequestMessage(envelope, content.dataMessage); - } else if (envelope.type === textsecure.protobuf.Envelope.Type.CIPHERTEXT) { + } else if ( + envelope.type === textsecure.protobuf.Envelope.Type.CIPHERTEXT || + // We also need to check for PREKEY_BUNDLE aswell if the session hasn't started. + // ref: libsignal-protocol.js:36120 + envelope.type === textsecure.protobuf.Envelope.Type.PREKEY_BUNDLE + ) { // If we get a cipher text and we are friends then we can mark keys as exchanged if (conversation && conversation.isFriend()) { await conversation.setKeyExchangeCompleted(true); @@ -1060,6 +1065,8 @@ MessageReceiver.prototype.extend({ } else if (content.receiptMessage) { return this.handleReceiptMessage(envelope, content.receiptMessage); } + if (envelope.preKeyBundleMessage) return null; + throw new Error('Unsupported content message'); }, handleCallMessage(envelope) { diff --git a/protos/SignalService.proto b/protos/SignalService.proto index ecf9743d1..e0de8f732 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -9,7 +9,7 @@ message Envelope { UNKNOWN = 0; CIPHERTEXT = 1; KEY_EXCHANGE = 2; - PREKEY_BUNDLE = 3; + PREKEY_BUNDLE = 3; //Used By Signal. DO NOT TOUCH! we don't use this at all. RECEIPT = 5; UNIDENTIFIED_SENDER = 6; FRIEND_REQUEST = 101; // contains prekeys + message and is using simple encryption From bd103e2ad2514bbf57567113ffe035f598552e7a Mon Sep 17 00:00:00 2001 From: Mikunj Date: Thu, 22 Nov 2018 09:59:38 +1100 Subject: [PATCH 11/17] Minor fixes. --- js/models/conversations.js | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/js/models/conversations.js b/js/models/conversations.js index cbdd250ce..45eec3785 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -62,13 +62,14 @@ * * `isKeyExchangeCompleted` return whether we know for certain * that both of our preKeyBundles have been exhanged. - * - This will be set when we receive a valid CIPHER message from the other user. + * - This will be set when we receive a valid CIPHER or + * PREKEY_BUNDLE message from the other user. * * Valid meaning we can decypher the message using the preKeys provided * or the keys we have stored. * * `isFriend` will determine whether we should send a FRIEND_REQUEST message. * - * `isKeyExhangeCompleted` will determine whether we keep + * `isKeyExchangeCompleted` will determine whether we keep * sending preKeyBundle to the other user. */ @@ -513,8 +514,9 @@ // Update our local state await this.updatePendingFriendRequests(); - // Send the notification - this.notifyFriendRequest(this.id, 'accepted') + // Send the notification if we had an outgoing friend request + if (pending.length > 0) + this.notifyFriendRequest(this.id, 'accepted') }, async onFriendRequestSent() { return this.updateFriendRequestUI(); @@ -1224,7 +1226,7 @@ ); // We also need to update any outgoing pending requests and set them to denied. - // when we get an incoming friend request. + // when we get an incoming friend request. const outgoing = await this.getPendingFriendRequests('outgoing'); await Promise.all( outgoing.map(async request => { From 484efd34a3b041fdbb782e59c0e048bf3df52325 Mon Sep 17 00:00:00 2001 From: Mikunj Date: Thu, 22 Nov 2018 10:46:29 +1100 Subject: [PATCH 12/17] Added friend request message expiration. --- _locales/en/messages.json | 4 ++ js/models/conversations.js | 63 +++++++++++++++++++- libtextsecure/outgoing_message.js | 2 +- ts/components/conversation/FriendRequest.tsx | 15 ++--- 4 files changed, 74 insertions(+), 10 deletions(-) diff --git a/_locales/en/messages.json b/_locales/en/messages.json index fe19c3d27..8d30bb871 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1590,6 +1590,10 @@ "message": "Friend request declined", "description": "Shown in the conversation history when the user declines a friend request" }, + "friendRequestExpired": { + "message": "Friend request expired", + "description": "Shown in the conversation history when the users friend request expires" + }, "friendRequestNotificationTitle": { "message": "Friend request", "description": "Shown in a notification title when receiving a friend request" diff --git a/js/models/conversations.js b/js/models/conversations.js index 45eec3785..6efffe316 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -81,6 +81,7 @@ verified: textsecure.storage.protocol.VerifiedStatus.DEFAULT, keyExchangeCompleted: false, blockInput: false, + unlockTimestamp: null, // Timestamp used for expiring friend requests. }; }, @@ -158,6 +159,7 @@ this.unset('lastMessageStatus'); this.updateTextInputState(); + this.setFriendRequestExpiryTimeout(); }, isMe() { @@ -214,6 +216,7 @@ await this.inProgressFetch; removeMessage(); }, + async onCalculatingPoW(pubKey, timestamp) { if (this.id !== pubKey) return; @@ -227,7 +230,6 @@ if (setToExpire) model.setToExpire(); return model; }, - format() { const { format } = PhoneNumber; const regionCode = storage.get('regionCode'); @@ -518,8 +520,65 @@ if (pending.length > 0) this.notifyFriendRequest(this.id, 'accepted') }, + async onFriendRequestTimeout() { + // Unset the timer + if (this.unlockTimer) + clearTimeout(this.unlockTimer); + + this.unlockTimer = null; + + // Set the unlock timestamp to null + if (this.get('unlockTimestamp')) { + this.set({ unlockTimestamp: null }); + await window.Signal.Data.updateConversation(this.id, this.attributes, { + Conversation: Whisper.Conversation, + }); + } + + // Change any pending outgoing friend requests to expired + const outgoing = await this.getPendingFriendRequests('outgoing'); + await Promise.all( + outgoing.map(async request => { + if (request.hasErrors()) return; + + request.set({ friendStatus: 'expired' }); + await window.Signal.Data.saveMessage(request.attributes, { + Message: Whisper.Message, + }); + this.trigger('updateMessage', request); + }) + ); + + // Update the UI + this.updateFriendRequestUI(); + }, async onFriendRequestSent() { - return this.updateFriendRequestUI(); + // Check if we need to set the friend request expiry + const unlockTimestamp = this.get('unlockTimestamp'); + const isFriend = await this.isFriend(); + if (!isFriend && !unlockTimestamp) { + // Expire the messages after 72 hours + const hourLockDuration = 72; + const ms = 60 * 60 * 1000 * hourLockDuration; + + this.set({ unlockTimestamp: Date.now() + ms }); + await window.Signal.Data.updateConversation(this.id, this.attributes, { + Conversation: Whisper.Conversation, + }); + + this.setFriendRequestExpiryTimeout(); + } + + this.updateFriendRequestUI(); + }, + setFriendRequestExpiryTimeout() { + const unlockTimestamp = this.get('unlockTimestamp'); + if (unlockTimestamp && !this.unlockTimer) { + const delta = Math.max(unlockTimestamp - Date.now(), 0); + this.unlockTimer = setTimeout(() => { + this.onFriendRequestTimeout(); + }, delta); + } }, isUnverified() { if (this.isPrivate()) { diff --git a/libtextsecure/outgoing_message.js b/libtextsecure/outgoing_message.js index 2687ebea2..39fbfab87 100644 --- a/libtextsecure/outgoing_message.js +++ b/libtextsecure/outgoing_message.js @@ -475,7 +475,7 @@ OutgoingMessage.prototype = { } if (this.fallBackEncryption && conversation) { - conversation.onFriendRequestSent(); + await conversation.onFriendRequestSent(); } }) .then(this.reloadDevicesAndSend(number, true)) diff --git a/ts/components/conversation/FriendRequest.tsx b/ts/components/conversation/FriendRequest.tsx index 71f27b251..8366b40ba 100644 --- a/ts/components/conversation/FriendRequest.tsx +++ b/ts/components/conversation/FriendRequest.tsx @@ -8,7 +8,7 @@ interface Props { text: string; direction: 'incoming' | 'outgoing'; status: string; - friendStatus: 'pending' | 'accepted' | 'declined'; + friendStatus: 'pending' | 'accepted' | 'declined' | 'expired'; i18n: Localizer; onAccept: () => void; onDecline: () => void; @@ -22,11 +22,13 @@ export class FriendRequest extends React.Component { switch (friendStatus) { case 'pending': - return `friendRequestPending`; + return 'friendRequestPending'; case 'accepted': - return `friendRequestAccepted`; + return 'friendRequestAccepted'; case 'declined': - return `friendRequestDeclined`; + return 'friendRequestDeclined'; + case 'expired': + return 'friendRequestExpired' default: throw new Error(`Invalid friend request status: ${friendStatus}`); } @@ -45,7 +47,6 @@ export class FriendRequest extends React.Component { - ); } @@ -137,7 +138,7 @@ export class FriendRequest extends React.Component { public render() { const { direction } = this.props; - + return (
{ 'module-message-friend-request__container', )} > -
Date: Thu, 22 Nov 2018 13:59:03 +1100 Subject: [PATCH 13/17] Store the state of the friend request inside the conversation. --- js/models/conversations.js | 33 ++++++++++++++++--------------- libtextsecure/message_receiver.js | 17 +++++++++------- 2 files changed, 27 insertions(+), 23 deletions(-) diff --git a/js/models/conversations.js b/js/models/conversations.js index 6efffe316..e1d367eec 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -79,6 +79,7 @@ return { unreadCount: 0, verified: textsecure.storage.protocol.VerifiedStatus.DEFAULT, + isFriend: false, keyExchangeCompleted: false, blockInput: false, unlockTimestamp: null, // Timestamp used for expiring friend requests. @@ -481,14 +482,8 @@ return (incoming.length > 0 || successfulOutgoing.length > 0); }, - async isFriend() { - // We are a friend IF: - // - We have the preKey bundle of the user OR - // - We have a session with the user - const preKeys = await window.Signal.Data.getContactPreKeyByIdentityKey(this.id); - // const session = await window.Signal.Data.getSessionsByNumber(this.id); - - return !!preKeys; + isFriend() { + return this.get('isFriend'); }, // Update any pending friend requests for the current user async updateFriendRequestUI() { @@ -496,8 +491,7 @@ this.updateTextInputState(); // We only update our friend requests if we have the user as a friend - const isFriend = await this.isFriend(); - if (!isFriend) return; + if (!this.isFriend()) return; // Update any pending outgoing messages const pending = await this.getPendingFriendRequests('outgoing'); @@ -520,6 +514,16 @@ if (pending.length > 0) this.notifyFriendRequest(this.id, 'accepted') }, + async onFriendRequestAccepted() { + if (!this.isFriend()) { + this.set({ isFriend: true }); + await window.Signal.Data.updateConversation(this.id, this.attributes, { + Conversation: Whisper.Conversation, + }); + } + + await this.updateFriendRequestUI(); + }, async onFriendRequestTimeout() { // Unset the timer if (this.unlockTimer) @@ -555,8 +559,7 @@ async onFriendRequestSent() { // Check if we need to set the friend request expiry const unlockTimestamp = this.get('unlockTimestamp'); - const isFriend = await this.isFriend(); - if (!isFriend && !unlockTimestamp) { + if (!this.isFriend() && !unlockTimestamp) { // Expire the messages after 72 hours const hourLockDuration = 72; const ms = 60 * 60 * 1000 * hourLockDuration; @@ -963,8 +966,7 @@ let messageWithSchema = null; // If we are a friend then let the user send the message normally - const isFriend = await this.isFriend(); - if (isFriend) { + if (this.isFriend()) { messageWithSchema = await upgradeMessageSchema({ type: 'outgoing', body, @@ -1101,8 +1103,7 @@ }, async updateTextInputState() { // Check if we need to disable the text field - const isFriend = await this.isFriend(); - if (!isFriend) { + if (!this.isFriend()) { // Disable the input if we're waiting for friend request approval const waiting = await this.waitingForFriendRequestApproval(); if (waiting) { diff --git a/libtextsecure/message_receiver.js b/libtextsecure/message_receiver.js index 2ddd81fe1..360764446 100644 --- a/libtextsecure/message_receiver.js +++ b/libtextsecure/message_receiver.js @@ -1020,12 +1020,13 @@ MessageReceiver.prototype.extend({ ); } - // Send a reply back - libloki.sendEmptyMessageWithPreKeys(pubKey); - + // Accept the friend request if (conversation) { - await conversation.updateFriendRequestUI(); + await conversation.onFriendRequestAccepted(); } + + // Send a reply back + libloki.sendEmptyMessageWithPreKeys(pubKey); } window.log.info(`Friend request for ${pubKey} was ${message.friendStatus}`, message); }, @@ -1047,10 +1048,12 @@ MessageReceiver.prototype.extend({ // ref: libsignal-protocol.js:36120 envelope.type === textsecure.protobuf.Envelope.Type.PREKEY_BUNDLE ) { - // If we get a cipher text and we are friends then we can mark keys as exchanged - if (conversation && conversation.isFriend()) { + // We know for sure that keys are exhanged + if (conversation) { await conversation.setKeyExchangeCompleted(true); - await conversation.updateFriendRequestUI(); + + // TODO: We should probably set this based on the PKB type + await conversation.onFriendRequestAccepted(); } } From fb8b0e1d4085054145a51a612c9e830a2a002e2d Mon Sep 17 00:00:00 2001 From: Mikunj Date: Thu, 22 Nov 2018 14:25:20 +1100 Subject: [PATCH 14/17] Auto accept friend request if we have both incoming and outgoing friend requests. --- js/models/conversations.js | 26 +++++++++--------- js/models/messages.js | 54 ++++++++++++++++++++++---------------- 2 files changed, 43 insertions(+), 37 deletions(-) diff --git a/js/models/conversations.js b/js/models/conversations.js index e1d367eec..2db01dc45 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -554,7 +554,8 @@ ); // Update the UI - this.updateFriendRequestUI(); + await this.updatePendingFriendRequests(); + await this.updateFriendRequestUI(); }, async onFriendRequestSent() { // Check if we need to set the friend request expiry @@ -1285,20 +1286,17 @@ .map(request => this._removeMessage(request.id)) ); - // We also need to update any outgoing pending requests and set them to denied. - // when we get an incoming friend request. + // If we have an outgoing friend request then + // we auto accept the incoming friend request const outgoing = await this.getPendingFriendRequests('outgoing'); - await Promise.all( - outgoing.map(async request => { - if (request.hasErrors()) return; - - request.set({ friendStatus: 'declined' }); - await window.Signal.Data.saveMessage(request.attributes, { - Message: Whisper.Message, - }); - this.trigger('updateMessage', request); - }) - ); + if (outgoing.length > 0) { + const current = this.messageCollection.find(i => i.id === message.id); + if (current) { + await current.acceptFriendRequest(); + } else { + window.log.debug('onNewMessage: Failed to find incoming friend request'); + } + } // Trigger an update if we removed or updated messages if (outgoing.length > 0 || incoming.length > 0) diff --git a/js/models/messages.js b/js/models/messages.js index b7483f1fe..d42606aea 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -297,34 +297,42 @@ // It doesn't need anything right now! return {}; }, - getPropsForFriendRequest() { - const friendStatus = this.get('friendStatus') || 'pending'; - const direction = this.get('direction') || 'incoming'; + + async acceptFriendRequest() { + if (this.get('friendStatus') !== 'pending') return; const conversation = this.getConversation(); - const onAccept = async () => { - this.set({ friendStatus: 'accepted' }); - await window.Signal.Data.saveMessage(this.attributes, { - Message: Whisper.Message, - }); + this.set({ friendStatus: 'accepted' }); + await window.Signal.Data.saveMessage(this.attributes, { + Message: Whisper.Message, + }); - window.Whisper.events.trigger('friendRequestUpdated', { - pubKey: conversation.id, - ...this.attributes, - }); - }; + window.Whisper.events.trigger('friendRequestUpdated', { + pubKey: conversation.id, + ...this.attributes, + }); + }, + async declineFriendRequest() { + if (this.get('friendStatus') !== 'pending') return; + const conversation = this.getConversation(); - const onDecline = async () => { - this.set({ friendStatus: 'declined' }); - await window.Signal.Data.saveMessage(this.attributes, { - Message: Whisper.Message, - }); + this.set({ friendStatus: 'declined' }); + await window.Signal.Data.saveMessage(this.attributes, { + Message: Whisper.Message, + }); - window.Whisper.events.trigger('friendRequestUpdated', { - pubKey: conversation.id, - ...this.attributes, - }); - }; + window.Whisper.events.trigger('friendRequestUpdated', { + pubKey: conversation.id, + ...this.attributes, + }); + }, + getPropsForFriendRequest() { + const friendStatus = this.get('friendStatus') || 'pending'; + const direction = this.get('direction') || 'incoming'; + const conversation = this.getConversation(); + + const onAccept = () => this.acceptFriendRequest(); + const onDecline = () => this.declineFriendRequest() const onDeleteConversation = async () => { // Delete the whole conversation From 50e8f65a7ea4ae055e812736e9d199390ae2a9d5 Mon Sep 17 00:00:00 2001 From: Mikunj Date: Thu, 22 Nov 2018 14:30:00 +1100 Subject: [PATCH 15/17] updated doc. --- js/models/conversations.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/js/models/conversations.js b/js/models/conversations.js index 2db01dc45..350f15b2d 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -57,8 +57,7 @@ * between isFriend() and isKeyExchangeCompleted(). * * `isFriend` returns whether we have accepted the other user as a friend. - * - This is implicitly checked by whether we have a session - * or we have the preKeyBundle of the user. + * - This is explicilty stored as a state in the conversation * * `isKeyExchangeCompleted` return whether we know for certain * that both of our preKeyBundles have been exhanged. From 4fd709be35cad339466a851df3e944fb011559a5 Mon Sep 17 00:00:00 2001 From: Mikunj Date: Thu, 22 Nov 2018 14:51:40 +1100 Subject: [PATCH 16/17] Removed unused state. --- js/models/conversations.js | 11 ----------- js/views/conversation_view.js | 11 ++--------- 2 files changed, 2 insertions(+), 20 deletions(-) diff --git a/js/models/conversations.js b/js/models/conversations.js index 350f15b2d..c03771c86 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -80,7 +80,6 @@ verified: textsecure.storage.protocol.VerifiedStatus.DEFAULT, isFriend: false, keyExchangeCompleted: false, - blockInput: false, unlockTimestamp: null, // Timestamp used for expiring friend requests. }; }, @@ -1094,31 +1093,21 @@ return true; }); }, - async updateBlockInput(blockInput) { - if (this.get('blockInput') === blockInput) return; - this.set({ blockInput }); - await window.Signal.Data.updateConversation(this.id, this.attributes, { - Conversation: Whisper.Conversation, - }); - }, async updateTextInputState() { // Check if we need to disable the text field if (!this.isFriend()) { // Disable the input if we're waiting for friend request approval const waiting = await this.waitingForFriendRequestApproval(); if (waiting) { - await this.updateBlockInput(true); this.trigger('disable:input', true); this.trigger('change:placeholder', 'disabled'); return; } // Tell the user to introduce themselves - await this.updateBlockInput(false); this.trigger('disable:input', false); this.trigger('change:placeholder', 'friend-request'); return; } - await this.updateBlockInput(false); this.trigger('disable:input', false); this.trigger('change:placeholder', 'chat'); }, diff --git a/js/views/conversation_view.js b/js/views/conversation_view.js index b71480657..8b499fb95 100644 --- a/js/views/conversation_view.js +++ b/js/views/conversation_view.js @@ -69,16 +69,9 @@ }, template: $('#conversation').html(), render_attributes() { - let sendMessagePlaceholder = 'sendMessageFriendRequest'; - const sendDisabled = this.model.get('blockInput'); - if (sendDisabled) { - sendMessagePlaceholder = 'sendMessageDisabled'; - } else if (this.model.isFriend()) { - sendMessagePlaceholder = 'sendMessage'; - } return { - 'disable-inputs': sendDisabled, - 'send-message': i18n(sendMessagePlaceholder), + 'disable-inputs': false, + 'send-message': i18n('sendMessage'), 'android-length-warning': i18n('androidMessageLengthWarning'), }; }, From f29a515fdf111e4ffd5acf1ffe4a36a40edc8de8 Mon Sep 17 00:00:00 2001 From: Mikunj Date: Thu, 22 Nov 2018 14:52:32 +1100 Subject: [PATCH 17/17] Fix up typos. --- js/models/conversations.js | 2 +- libtextsecure/message_receiver.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/js/models/conversations.js b/js/models/conversations.js index c03771c86..53efe99ad 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -60,7 +60,7 @@ * - This is explicilty stored as a state in the conversation * * `isKeyExchangeCompleted` return whether we know for certain - * that both of our preKeyBundles have been exhanged. + * that both of our preKeyBundles have been exchanged. * - This will be set when we receive a valid CIPHER or * PREKEY_BUNDLE message from the other user. * * Valid meaning we can decypher the message using the preKeys provided diff --git a/libtextsecure/message_receiver.js b/libtextsecure/message_receiver.js index 360764446..cb305fbda 100644 --- a/libtextsecure/message_receiver.js +++ b/libtextsecure/message_receiver.js @@ -1048,7 +1048,7 @@ MessageReceiver.prototype.extend({ // ref: libsignal-protocol.js:36120 envelope.type === textsecure.protobuf.Envelope.Type.PREKEY_BUNDLE ) { - // We know for sure that keys are exhanged + // We know for sure that keys are exchanged if (conversation) { await conversation.setKeyExchangeCompleted(true);