From 8700112f6d7e021cfdf3e08ec29ff618743bb0b3 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Fri, 11 Aug 2017 17:16:22 -0700 Subject: [PATCH] Decrypt any IncomingIdentityKeyError still sticking around FREEBIE --- js/libtextsecure.js | 125 ++++++++++++++++++++++++++---- js/models/conversations.js | 120 +++++++++++++++++++++++++++- js/views/conversation_view.js | 12 ++- libtextsecure/errors.js | 4 +- libtextsecure/message_receiver.js | 72 ++++++++++++++--- libtextsecure/sendmessage.js | 49 +++++++++++- 6 files changed, 351 insertions(+), 31 deletions(-) diff --git a/js/libtextsecure.js b/js/libtextsecure.js index 3d4efd01c..98d20df5c 100644 --- a/js/libtextsecure.js +++ b/js/libtextsecure.js @@ -31,7 +31,9 @@ ReplayableError.prototype.constructor = ReplayableError; ReplayableError.prototype.replay = function() { - return registeredFunctions[this.functionCode].apply(window, this.args); + var argumentsAsArray = Array.prototype.slice.call(arguments, 0); + var args = this.args.concat(argumentsAsArray); + return registeredFunctions[this.functionCode].apply(window, args); }; function IncomingIdentityKeyError(number, message, key) { @@ -38895,11 +38897,36 @@ MessageReceiver.prototype.extend({ .then(decryptAttachment) .then(updateAttachment); }, - tryMessageAgain: function(from, ciphertext) { + validateRetryContentMessage: function(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 + var 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 + var data = content.dataMessage; + if (data && !data.attachments.length && !data.body && !data.expireTimer && !data.flags && !data.group) { + return false; + } + + return true; + }, + tryMessageAgain: function(from, ciphertext, message) { var address = libsignal.SignalProtocolAddress.fromString(from); + var sentAt = message.sent_at || Date.now(); var ourNumber = textsecure.storage.user.getNumber(); - var number = address.toString().split('.')[0]; + var number = address.getName(); + var device = address.getDeviceId(); var options = {}; // No limit on message keys if we're communicating with our other devices @@ -38910,19 +38937,42 @@ MessageReceiver.prototype.extend({ var sessionCipher = new libsignal.SessionCipher(textsecure.storage.protocol, address, options); console.log('retrying prekey whisper message'); return this.decryptPreKeyWhisperMessage(ciphertext, sessionCipher, address).then(function(plaintext) { - var finalMessage = textsecure.protobuf.DataMessage.decode(plaintext); + var envelope = { + source: number, + sourceDevice: device, + timestamp: { + toNumber: function() { + return sentAt; + } + } + }; - var p = Promise.resolve(); - if ((finalMessage.flags & textsecure.protobuf.DataMessage.Flags.END_SESSION) - == textsecure.protobuf.DataMessage.Flags.END_SESSION && - finalMessage.sync !== null) { - var number = address.getName(); - p = this.handleEndSession(number); + // Before June, all incoming messages were still DataMessage: + // - iOS: Michael Kirk says that they were sending Legacy messages until June + // - Desktop: https://github.com/WhisperSystems/Signal-Desktop/commit/e8548879db405d9bcd78b82a456ad8d655592c0f + // - Android: https://github.com/WhisperSystems/libsignal-service-java/commit/61a75d023fba950ff9b4c75a249d1a3408e12958 + // + // var d = new Date('2017-06-01T07:00:00.000Z'); + // d.getTime(); + var startOfJune = 1496300400000; + if (sentAt < startOfJune) { + return this.innerHandleLegacyMessage(envelope, plaintext); } - return p.then(function() { - return this.processDecrypted(finalMessage); - }.bind(this)); + // 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 + var 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); }.bind(this)); }, handleEndSession: function(number) { @@ -39433,8 +39483,55 @@ MessageSender.prototype = { return outgoing.transmitMessage(number, jsonData, timestamp); }, + validateRetryContentMessage: function(content) { + // We want at least one field set, but not more than one + var count = 0; + count += content.syncMessage ? 1 : 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 + var data = content.dataMessage; + if (data && !data.attachments.length && !data.body && !data.expireTimer && !data.flags && !data.group) { + return false; + } + + return true; + }, + + getRetryProto: function(message, timestamp) { + // If message was sent before v0.41.3 was released on Aug 7, then it was most certainly a DataMessage + // + // var d = new Date('2017-08-07T07:00:00.000Z'); + // d.getTime(); + var august7 = 1502089200000; + if (timestamp < august7) { + return textsecure.protobuf.DataMessage.decode(message); + } + + // 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 + var proto = textsecure.protobuf.Content.decode(message); + + // But it might also result in an invalid object, so we try to detect that + if (this.validateRetryContentMessage(proto)) { + return proto; + } + + return textsecure.protobuf.DataMessage.decode(message); + } catch(e) { + // If this call throws, something has really gone wrong, we'll fail to send + return textsecure.protobuf.DataMessage.decode(message); + } + }, + tryMessageAgain: function(number, encodedMessage, timestamp) { - var proto = textsecure.protobuf.Content.decode(encodedMessage); + var proto = this.getRetryProto(encodedMessage, timestamp); return this.sendIndividualProto(number, proto, timestamp); }, diff --git a/js/models/conversations.js b/js/models/conversations.js index 3d1ce4dcc..24b960651 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -7,7 +7,7 @@ // TODO: Factor out private and group subclasses of Conversation - var COLORS = [ + var COLORS = [ 'red', 'pink', 'purple', @@ -25,6 +25,22 @@ 'blue_grey', ]; + function constantTimeEqualArrayBuffers(ab1, ab2) { + if (!(ab1 instanceof ArrayBuffer && ab2 instanceof ArrayBuffer)) { + return false; + } + if (ab1.byteLength !== ab2.byteLength) { + return false; + } + var result = true; + var ta1 = new Uint8Array(ab1); + var ta2 = new Uint8Array(ab2); + for (var i = 0; i < ab1.byteLength; ++i) { + if (ta1[i] !== ta2[i]) { result = false; } + } + return result; + } + Whisper.Conversation = Backbone.Model.extend({ database: Whisper.Database, storeName: 'conversations', @@ -169,6 +185,108 @@ return textsecure.messaging.syncVerification(number, state, key); }); }, + getIdentityKeys: function() { + var lookup = {}; + + if (this.isPrivate()) { + return textsecure.storage.protocol.loadIdentityKey(this.id).then(function(key) { + lookup[this.id] = key; + return lookup; + }.bind(this)).catch(function(error) { + console.log( + 'getIdentityKeys error for conversation', + this.id, + error && error.stack ? error.stack : error + ); + return lookup; + }.bind(this)); + } else { + return Promise.all(this.contactCollection.map(function(contact) { + return textsecure.storage.protocol.loadIdentityKey(contact.id).then(function(key) { + lookup[contact.id] = key; + }).catch(function(error) { + console.log( + 'getIdentityKeys error for group member', + contact.id, + error && error.stack ? error.stack : error + ); + }); + })).then(function() { + return lookup; + }); + } + }, + replay: function(error, message) { + var replayable = new textsecure.ReplayableError(error); + return replayable.replay(message.attributes).catch(function(error) { + console.log( + 'replay error:', + error && error.stack ? error.stack : error + ); + }); + }, + decryptOldIncomingKeyErrors: function() { + // We want to run just once per conversation + if (this.get('decryptedOldIncomingKeyErrors')) { + return Promise.resolve(); + } + console.log('decryptOldIncomingKeyErrors start'); + + var messages = this.messageCollection.filter(function(message) { + var errors = message.get('errors'); + if (!errors || !errors[0]) { + return false; + } + var error = _.find(errors, function(error) { + return error.name === 'IncomingIdentityKeyError'; + }); + + return Boolean(error); + }); + + var markComplete = function() { + console.log('decryptOldIncomingKeyErrors complete'); + return new Promise(function(resolve) { + this.save({decryptedOldIncomingKeyErrors: true}).always(resolve); + }.bind(this)); + }.bind(this); + + if (!messages.length) { + return markComplete(); + } + + console.log('decryptOldIncomingKeyErrors found', messages.length, 'messages to process'); + var safeDelete = function(message) { + return new Promise(function(resolve) { + message.destroy().always(resolve); + }); + }; + + return this.getIdentityKeys().then(function(lookup) { + return Promise.all(_.map(messages, function(message) { + var source = message.get('source'); + var error = _.find(message.get('errors'), function(error) { + return error.name === 'IncomingIdentityKeyError'; + }); + + var key = lookup[source]; + if (!key) { + return; + } + + if (constantTimeEqualArrayBuffers(key, error.identityKey)) { + return this.replay(error, message).then(function() { + return safeDelete(message); + }); + } + }.bind(this))); + }.bind(this)).catch(function(error) { + console.log( + 'decryptOldIncomingKeyErrors error:', + error && error.stack ? error.stack : error + ); + }).then(markComplete); + }, isVerified: function() { if (this.isPrivate()) { return this.get('verified') === this.verifiedEnum.VERIFIED; diff --git a/js/views/conversation_view.js b/js/views/conversation_view.js index 87840444b..f1936df92 100644 --- a/js/views/conversation_view.js +++ b/js/views/conversation_view.js @@ -429,14 +429,22 @@ this.lastActivity = Date.now(); this.statusFetch = this.throttledGetProfiles().then(function() { - this.model.updateVerified().then(function() { + return this.model.updateVerified().then(function() { this.onVerifiedChange(); this.statusFetch = null; console.log('done with status fetch'); }.bind(this)); }.bind(this)); - Promise.all([this.statusFetch, this.inProgressFetch]) + // We schedule our catch-up decrypt right after any in-progress fetch of + // messages from the database, then ensure that the loading screen is only + // dismissed when that is complete. + var messagesLoaded = this.inProgressFetch || Promise.resolve(); + messagesLoaded = messagesLoaded.then(function() { + return this.model.decryptOldIncomingKeyErrors(); + }.bind(this)); + + Promise.all([this.statusFetch, messagesLoaded]) .then(this.onLoaded.bind(this), this.onLoaded.bind(this)); this.view.resetScrollPosition(); diff --git a/libtextsecure/errors.js b/libtextsecure/errors.js index 4e68279e7..1f9799e2b 100644 --- a/libtextsecure/errors.js +++ b/libtextsecure/errors.js @@ -30,7 +30,9 @@ ReplayableError.prototype.constructor = ReplayableError; ReplayableError.prototype.replay = function() { - return registeredFunctions[this.functionCode].apply(window, this.args); + var argumentsAsArray = Array.prototype.slice.call(arguments, 0); + var args = this.args.concat(argumentsAsArray); + return registeredFunctions[this.functionCode].apply(window, args); }; function IncomingIdentityKeyError(number, message, key) { diff --git a/libtextsecure/message_receiver.js b/libtextsecure/message_receiver.js index 2771c02a5..2b3d8ce9b 100644 --- a/libtextsecure/message_receiver.js +++ b/libtextsecure/message_receiver.js @@ -646,11 +646,36 @@ MessageReceiver.prototype.extend({ .then(decryptAttachment) .then(updateAttachment); }, - tryMessageAgain: function(from, ciphertext) { + validateRetryContentMessage: function(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 + var 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 + var data = content.dataMessage; + if (data && !data.attachments.length && !data.body && !data.expireTimer && !data.flags && !data.group) { + return false; + } + + return true; + }, + tryMessageAgain: function(from, ciphertext, message) { var address = libsignal.SignalProtocolAddress.fromString(from); + var sentAt = message.sent_at || Date.now(); var ourNumber = textsecure.storage.user.getNumber(); - var number = address.toString().split('.')[0]; + var number = address.getName(); + var device = address.getDeviceId(); var options = {}; // No limit on message keys if we're communicating with our other devices @@ -661,19 +686,42 @@ MessageReceiver.prototype.extend({ var sessionCipher = new libsignal.SessionCipher(textsecure.storage.protocol, address, options); console.log('retrying prekey whisper message'); return this.decryptPreKeyWhisperMessage(ciphertext, sessionCipher, address).then(function(plaintext) { - var finalMessage = textsecure.protobuf.DataMessage.decode(plaintext); + var envelope = { + source: number, + sourceDevice: device, + timestamp: { + toNumber: function() { + return sentAt; + } + } + }; - var p = Promise.resolve(); - if ((finalMessage.flags & textsecure.protobuf.DataMessage.Flags.END_SESSION) - == textsecure.protobuf.DataMessage.Flags.END_SESSION && - finalMessage.sync !== null) { - var number = address.getName(); - p = this.handleEndSession(number); + // Before June, all incoming messages were still DataMessage: + // - iOS: Michael Kirk says that they were sending Legacy messages until June + // - Desktop: https://github.com/WhisperSystems/Signal-Desktop/commit/e8548879db405d9bcd78b82a456ad8d655592c0f + // - Android: https://github.com/WhisperSystems/libsignal-service-java/commit/61a75d023fba950ff9b4c75a249d1a3408e12958 + // + // var d = new Date('2017-06-01T07:00:00.000Z'); + // d.getTime(); + var startOfJune = 1496300400000; + if (sentAt < startOfJune) { + return this.innerHandleLegacyMessage(envelope, plaintext); } - return p.then(function() { - return this.processDecrypted(finalMessage); - }.bind(this)); + // 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 + var 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); }.bind(this)); }, handleEndSession: function(number) { diff --git a/libtextsecure/sendmessage.js b/libtextsecure/sendmessage.js index 43f22ace7..9baa12167 100644 --- a/libtextsecure/sendmessage.js +++ b/libtextsecure/sendmessage.js @@ -143,8 +143,55 @@ MessageSender.prototype = { return outgoing.transmitMessage(number, jsonData, timestamp); }, + validateRetryContentMessage: function(content) { + // We want at least one field set, but not more than one + var count = 0; + count += content.syncMessage ? 1 : 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 + var data = content.dataMessage; + if (data && !data.attachments.length && !data.body && !data.expireTimer && !data.flags && !data.group) { + return false; + } + + return true; + }, + + getRetryProto: function(message, timestamp) { + // If message was sent before v0.41.3 was released on Aug 7, then it was most certainly a DataMessage + // + // var d = new Date('2017-08-07T07:00:00.000Z'); + // d.getTime(); + var august7 = 1502089200000; + if (timestamp < august7) { + return textsecure.protobuf.DataMessage.decode(message); + } + + // 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 + var proto = textsecure.protobuf.Content.decode(message); + + // But it might also result in an invalid object, so we try to detect that + if (this.validateRetryContentMessage(proto)) { + return proto; + } + + return textsecure.protobuf.DataMessage.decode(message); + } catch(e) { + // If this call throws, something has really gone wrong, we'll fail to send + return textsecure.protobuf.DataMessage.decode(message); + } + }, + tryMessageAgain: function(number, encodedMessage, timestamp) { - var proto = textsecure.protobuf.Content.decode(encodedMessage); + var proto = this.getRetryProto(encodedMessage, timestamp); return this.sendIndividualProto(number, proto, timestamp); },