From 9c736df7d0dad6bae1aa7536010e4b284fd537ab Mon Sep 17 00:00:00 2001 From: lilia Date: Fri, 19 Dec 2014 16:49:18 -0800 Subject: [PATCH] Handle incoming identity key changes This ended up turning into a rewrite/refactor of the background page. For best results, view this diff with `-w` to ignore whitespace. In order to support retrying message decryption, possibly at a much later time than the message is received, we now implement the following: Each message is saved before it is decrypted. This generates a unique message_id which is later used to update the database entry with the message contents, or with any errors generated during processing. When an IncomingIdentityKeyError occurs, we catch it and save it on the model, then update the front end as usual. When the user clicks to accept the new key, the error is replayed, which causes the message to be decrypted and then passed to the background page for normal processing. --- js/background.js | 156 +++++++++++++++++++++++++++++++----- js/helpers.js | 201 +++++++++++++++++++++++------------------------ js/protocol.js | 14 +++- 3 files changed, 248 insertions(+), 123 deletions(-) diff --git a/js/background.js b/js/background.js index b650b27b1..f36d255b1 100644 --- a/js/background.js +++ b/js/background.js @@ -16,28 +16,148 @@ ;(function() { 'use strict'; + var conversations = new Whisper.ConversationCollection(); + var messages = new Whisper.MessageCollection(); + + if (!localStorage.getItem('first_install_ran')) { + localStorage.setItem('first_install_ran', 1); + extension.navigator.tabs.create("options.html"); + } + + if (textsecure.registration.isDone()) { + init(); + } else { + extension.on('registration_done', init); + } function init() { - if (!localStorage.getItem('first_install_ran')) { - localStorage.setItem('first_install_ran', 1); - extension.navigator.tabs.create("options.html"); - } else { - if (textsecure.registration.isDone()) { - var conversations = new Whisper.ConversationCollection(); - textsecure.subscribeToPush(function(message) { - conversations.addIncomingMessage(message).then(function(message) { - extension.trigger('message', message); + if (!textsecure.registration.isDone()) { return; } + + // initialize the socket and start listening for messages + var socket = textsecure.api.getMessageWebsocket(); + new WebSocketResource(socket, function(request) { + // TODO: handle different types of requests. for now we only expect + // PUT /messages + textsecure.protocol.decryptWebsocketMessage(request.body).then(function(plaintext) { + var proto = textsecure.protobuf.IncomingPushMessageSignal.decode(plaintext); + // After this point, decoding errors are not the server's + // fault, and we should handle them gracefully and tell the + // user they received an invalid message + request.respond(200, 'OK'); + + if (proto.type === textsecure.protobuf.IncomingPushMessageSignal.Type.RECEIPT) { + onDeliveryReceipt(proto); + } else { + onMessageReceived(proto); + } + + }).catch(function(e) { + console.log("Error handling incoming message:", e); + extension.trigger('error', e); + request.respond(500, 'Bad encrypted websocket message'); + }); + }); + }; + + function onMessageReceived(pushMessage) { + var now = new Date().getTime(); + var timestamp = pushMessage.timestamp.toNumber(); + + var conversation = conversations.add({ + id : pushMessage.source, + type : 'private' + }, { merge : true } ); + + var message = messages.add({ + source : pushMessage.source, + sourceDevice : pushMessage.sourceDevice, + relay : pushMessage.relay, + sent_at : timestamp, + received_at : now, + conversationId : pushMessage.source, + type : 'incoming' + }); + + var newUnreadCount = textsecure.storage.getUnencrypted("unreadCount", 0) + 1; + textsecure.storage.putUnencrypted("unreadCount", newUnreadCount); + extension.navigator.setBadgeText(newUnreadCount); + + conversation.save().then(function() { + message.save().then(function() { + return new Promise(function(resolve) { + resolve(textsecure.protocol.handleIncomingPushMessageProto(pushMessage).then( + function(pushMessageContent) { + handlePushMessageContent(pushMessageContent, message); + } + )); + }).catch(function(e) { + if (e.name === 'IncomingIdentityKeyError') { + e.args.push(message.id); + message.save({ errors : [e] }).then(function() { + extension.trigger('message', message); // notify frontend listeners + }); + } else { + throw e; + } + }); + }); + }); + }; + + extension.on('message:decrypted', function(options) { + var message = messages.add({id: options.message_id}); + message.fetch().then(function() { + var pushMessageContent = handlePushMessageContent( + new textsecure.protobuf.PushMessageContent(options.data), + message + ); + }); + }); + + function handlePushMessageContent(pushMessageContent, message) { + // This function can be called from the background script on an + // incoming message or from the frontend after the user accepts an + // identity key change. + return textsecure.processDecrypted(pushMessageContent).then(function(pushMessageContent) { + var now = new Date().getTime(); + var source = message.get('source'); + var conversationId = pushMessageContent.group ? pushMessageContent.group.id : source; + var conversation = conversations.add({id: conversationId}, {merge: true}); + conversation.fetch().always(function() { + var attributes = { active_at: now }; + if (pushMessageContent.group) { + attributes = { + groupId : pushMessageContent.group.id, + name : pushMessageContent.group.name, + type : 'group', + }; + } else { + attributes = { + name : source, + type : 'private' + }; + } + conversation.set(attributes); + + message.set({ + body : pushMessageContent.body, + conversationId : conversation.id, + attachments : pushMessageContent.attachments, + decrypted_at : now, + errors : [] + }); + + conversation.save().then(function() { + message.save().then(function() { + extension.trigger('message', message); // notify frontend listeners }); - console.log("Got message from " + message.pushMessage.source + "." + message.pushMessage.sourceDevice + - ': "' + getString(message.message.body) + '"'); - var newUnreadCount = textsecure.storage.getUnencrypted("unreadCount", 0) + 1; - textsecure.storage.putUnencrypted("unreadCount", newUnreadCount); - extension.navigator.setBadgeText(newUnreadCount); }); - } - } + }); + }); + } + + function onDeliveryReceipt(pushMessage) { + console.log('delivery receipt', pushMessage.source, timestamp); }; - extension.on('registration_done', init); - init(); })(); diff --git a/js/helpers.js b/js/helpers.js index dc1c6a39c..cf9f4284e 100644 --- a/js/helpers.js +++ b/js/helpers.js @@ -126,115 +126,112 @@ window.textsecure.throwHumanError = function(error, type, humanError) { throw e; } -// message_callback({message: decryptedMessage, pushMessage: server-providedPushMessage}) -window.textsecure.subscribeToPush = function(message_callback) { - var socket = textsecure.api.getMessageWebsocket(); - - var resource = new WebSocketResource(socket, function(request) { - // TODO: handle different types of requests. for now we only receive - // PUT /messages - textsecure.protocol.decryptWebsocketMessage(request.body).then(function(plaintext) { - var proto = textsecure.protobuf.IncomingPushMessageSignal.decode(plaintext); - // After this point, a) decoding errors are not the server's fault, and - // b) we should handle them gracefully and tell the user they received an invalid message - request.respond(200, 'OK'); - - return textsecure.protocol.handleIncomingPushMessageProto(proto).then(function(decrypted) { - // Delivery receipt - if (decrypted === null) - //TODO: Pass to UI - return; +var handleAttachment = function(attachment) { + function getAttachment() { + return textsecure.api.getAttachment(attachment.id.toString()); + } + + function decryptAttachment(encrypted) { + return textsecure.protocol.decryptAttachment( + encrypted, + attachment.key.toArrayBuffer() + ); + } + + function updateAttachment(data) { + attachment.data = data; + } + + return getAttachment(). + then(decryptAttachment). + then(updateAttachment); +}; + +textsecure.processDecrypted = function(decrypted) { - // Now that its decrypted, validate the message and clean it up for consumer processing - // Note that messages may (generally) only perform one action and we ignore remaining fields - // after the first action. + // Now that its decrypted, validate the message and clean it up for consumer processing + // Note that messages may (generally) only perform one action and we ignore remaining fields + // after the first action. - if (decrypted.flags == null) - decrypted.flags = 0; + if (decrypted.flags == null) + decrypted.flags = 0; + + if ((decrypted.flags & textsecure.protobuf.PushMessageContent.Flags.END_SESSION) + == textsecure.protobuf.PushMessageContent.Flags.END_SESSION) + return; + if (decrypted.flags != 0) { + throw new Error("Unknown flags in message"); + } - if ((decrypted.flags & textsecure.protobuf.PushMessageContent.Flags.END_SESSION) - == textsecure.protobuf.PushMessageContent.Flags.END_SESSION) + var promises = []; + + if (decrypted.group !== null) { + decrypted.group.id = getString(decrypted.group.id); + var existingGroup = textsecure.storage.groups.getNumbers(decrypted.group.id); + if (existingGroup === undefined) { + if (decrypted.group.type != textsecure.protobuf.PushMessageContent.GroupContext.Type.UPDATE) { + throw new Error("Got message for unknown group"); + } + textsecure.storage.groups.createNewGroup(decrypted.group.members, decrypted.group.id); + } else { + var fromIndex = existingGroup.indexOf(proto.source); + + if (fromIndex < 0) { + //TODO: This could be indication of a race... + throw new Error("Sender was not a member of the group they were sending from"); + } + + switch(decrypted.group.type) { + case textsecure.protobuf.PushMessageContent.GroupContext.Type.UPDATE: + if (decrypted.group.avatar !== null) + promises.push(handleAttachment(decrypted.group.avatar)); + + if (existingGroup.filter(function(number) { decrypted.group.members.indexOf(number) < 0 }).length != 0) { + throw new Error("Attempted to remove numbers from group with an UPDATE"); + } + decrypted.group.added = decrypted.group.members.filter(function(number) { return existingGroup.indexOf(number) < 0; }); + + var newGroup = textsecure.storage.groups.addNumbers(decrypted.group.id, decrypted.group.added); + if (newGroup.length != decrypted.group.members.length || + newGroup.filter(function(number) { return decrypted.group.members.indexOf(number) < 0; }).length != 0) { + throw new Error("Error calculating group member difference"); + } + + //TODO: Also follow this path if avatar + name haven't changed (ie we should start storing those) + if (decrypted.group.avatar === null && decrypted.group.added.length == 0 && decrypted.group.name === null) { return; - if (decrypted.flags != 0) - throw new Error("Unknown flags in message"); - - var handleAttachment = function(attachment) { - return textsecure.api.getAttachment(attachment.id.toString()).then(function(encryptedBin) { - return textsecure.protocol.decryptAttachment(encryptedBin, attachment.key.toArrayBuffer()).then(function(decryptedBin) { - attachment.data = decryptedBin; - }); - }); - }; - - var promises = []; - - if (decrypted.group !== null) { - decrypted.group.id = getString(decrypted.group.id); - var existingGroup = textsecure.storage.groups.getNumbers(decrypted.group.id); - if (existingGroup === undefined) { - if (decrypted.group.type != textsecure.protobuf.PushMessageContent.GroupContext.Type.UPDATE) - throw new Error("Got message for unknown group"); - textsecure.storage.groups.createNewGroup(decrypted.group.members, decrypted.group.id); - } else { - var fromIndex = existingGroup.indexOf(proto.source); - - if (fromIndex < 0) //TODO: This could be indication of a race... - throw new Error("Sender was not a member of the group they were sending from"); - - switch(decrypted.group.type) { - case textsecure.protobuf.PushMessageContent.GroupContext.Type.UPDATE: - if (decrypted.group.avatar !== null) - promises.push(handleAttachment(decrypted.group.avatar)); - - if (existingGroup.filter(function(number) { decrypted.group.members.indexOf(number) < 0 }).length != 0) - throw new Error("Attempted to remove numbers from group with an UPDATE"); - decrypted.group.added = decrypted.group.members.filter(function(number) { return existingGroup.indexOf(number) < 0; }); - - var newGroup = textsecure.storage.groups.addNumbers(decrypted.group.id, decrypted.group.added); - if (newGroup.length != decrypted.group.members.length || - newGroup.filter(function(number) { return decrypted.group.members.indexOf(number) < 0; }).length != 0) - throw new Error("Error calculating group member difference"); - - //TODO: Also follow this path if avatar + name haven't changed (ie we should start storing those) - if (decrypted.group.avatar === null && decrypted.group.added.length == 0 && decrypted.group.name === null) - return; - - //TODO: Strictly verify all numbers (ie dont let verifyNumber do any user-magic tweaking) - - decrypted.body = null; - decrypted.attachments = []; - - break; - case textsecure.protobuf.PushMessageContent.GroupContext.Type.QUIT: - textsecure.storage.groups.removeNumber(decrypted.group.id, proto.source); - - decrypted.body = null; - decrypted.attachments = []; - case textsecure.protobuf.PushMessageContent.GroupContext.Type.DELIVER: - decrypted.group.name = null; - decrypted.group.members = []; - decrypted.group.avatar = null; - - break; - default: - throw new Error("Unknown group message type"); - } - } } - for (var i in decrypted.attachments) - promises.push(handleAttachment(decrypted.attachments[i])); - return Promise.all(promises).then(function() { - message_callback({pushMessage: proto, message: decrypted}); - }); - }) - }).catch(function(e) { - // TODO: Show "Invalid message" messages? - console.log("Error handling incoming message: "); - console.log(e); - }); + //TODO: Strictly verify all numbers (ie dont let verifyNumber do any user-magic tweaking) + + decrypted.body = null; + decrypted.attachments = []; + + break; + case textsecure.protobuf.PushMessageContent.GroupContext.Type.QUIT: + textsecure.storage.groups.removeNumber(decrypted.group.id, proto.source); + + decrypted.body = null; + decrypted.attachments = []; + case textsecure.protobuf.PushMessageContent.GroupContext.Type.DELIVER: + decrypted.group.name = null; + decrypted.group.members = []; + decrypted.group.avatar = null; + + break; + default: + throw new Error("Unknown group message type"); + } + } + } + + for (var i in decrypted.attachments) { + promises.push(handleAttachment(decrypted.attachments[i])); + } + return Promise.all(promises).then(function() { + return decrypted; }); -}; +} window.textsecure.registerSingleDevice = function(number, verificationCode, stepDone) { var signalingKey = textsecure.crypto.getRandomBytes(32 + 20); diff --git a/js/protocol.js b/js/protocol.js index 8d5d9813f..a0d2f067b 100644 --- a/js/protocol.js +++ b/js/protocol.js @@ -362,9 +362,17 @@ window.textsecure.protocol = function() { }); } - var wipeIdentityAndTryMessageAgain = function(from, encodedMessage) { - //TODO: Wipe identity key! - return handlePreKeyWhisperMessage(from, encodedMessage); + var wipeIdentityAndTryMessageAgain = function(from, encodedMessage, message_id) { + // Wipe identity key! + textsecure.storage.removeEncrypted("devices" + from.split('.')[0]); + return handlePreKeyWhisperMessage(from, encodedMessage).then( + function(pushMessageContent) { + extension.trigger('message:decrypted', { + message_id : message_id, + data : pushMessageContent + }); + } + ); } textsecure.replay.registerFunction(wipeIdentityAndTryMessageAgain, textsecure.replay.Type.INIT_SESSION);