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.
This commit is contained in:
lilia 2014-12-19 16:49:18 -08:00
parent 5762e59c41
commit 9c736df7d0
3 changed files with 241 additions and 116 deletions

View file

@ -16,28 +16,148 @@
;(function() { ;(function() {
'use strict'; '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() { function init() {
if (!localStorage.getItem('first_install_ran')) { if (!textsecure.registration.isDone()) { return; }
localStorage.setItem('first_install_ran', 1);
extension.navigator.tabs.create("options.html"); // initialize the socket and start listening for messages
} else { var socket = textsecure.api.getMessageWebsocket();
if (textsecure.registration.isDone()) { new WebSocketResource(socket, function(request) {
var conversations = new Whisper.ConversationCollection(); // TODO: handle different types of requests. for now we only expect
textsecure.subscribeToPush(function(message) { // PUT /messages <encrypted IncomingPushMessageSignal>
conversations.addIncomingMessage(message).then(function(message) { textsecure.protocol.decryptWebsocketMessage(request.body).then(function(plaintext) {
extension.trigger('message', message); var proto = textsecure.protobuf.IncomingPushMessageSignal.decode(plaintext);
}); // After this point, decoding errors are not the server's
console.log("Got message from " + message.pushMessage.source + "." + message.pushMessage.sourceDevice + // fault, and we should handle them gracefully and tell the
': "' + getString(message.message.body) + '"'); // user they received an invalid message
var newUnreadCount = textsecure.storage.getUnencrypted("unreadCount", 0) + 1; request.respond(200, 'OK');
textsecure.storage.putUnencrypted("unreadCount", newUnreadCount);
extension.navigator.setBadgeText(newUnreadCount); 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
});
});
});
});
}
function onDeliveryReceipt(pushMessage) {
console.log('delivery receipt', pushMessage.source, timestamp);
}; };
extension.on('registration_done', init);
init();
})(); })();

View file

@ -126,115 +126,112 @@ window.textsecure.throwHumanError = function(error, type, humanError) {
throw e; throw e;
} }
// message_callback({message: decryptedMessage, pushMessage: server-providedPushMessage}) var handleAttachment = function(attachment) {
window.textsecure.subscribeToPush = function(message_callback) { function getAttachment() {
var socket = textsecure.api.getMessageWebsocket(); return textsecure.api.getAttachment(attachment.id.toString());
}
var resource = new WebSocketResource(socket, function(request) { function decryptAttachment(encrypted) {
// TODO: handle different types of requests. for now we only receive return textsecure.protocol.decryptAttachment(
// PUT /messages <base64-encoded encrypted IncomingPushMessageSignal> encrypted,
textsecure.protocol.decryptWebsocketMessage(request.body).then(function(plaintext) { attachment.key.toArrayBuffer()
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) { function updateAttachment(data) {
// Delivery receipt attachment.data = data;
if (decrypted === null) }
//TODO: Pass to UI
return;
// Now that its decrypted, validate the message and clean it up for consumer processing return getAttachment().
// Note that messages may (generally) only perform one action and we ignore remaining fields then(decryptAttachment).
// after the first action. then(updateAttachment);
};
if (decrypted.flags == null) textsecure.processDecrypted = function(decrypted) {
decrypted.flags = 0;
if ((decrypted.flags & textsecure.protobuf.PushMessageContent.Flags.END_SESSION) // Now that its decrypted, validate the message and clean it up for consumer processing
== textsecure.protobuf.PushMessageContent.Flags.END_SESSION) // Note that messages may (generally) only perform one action and we ignore remaining fields
return; // after the first action.
if (decrypted.flags != 0)
throw new Error("Unknown flags in message");
var handleAttachment = function(attachment) { if (decrypted.flags == null)
return textsecure.api.getAttachment(attachment.id.toString()).then(function(encryptedBin) { decrypted.flags = 0;
return textsecure.protocol.decryptAttachment(encryptedBin, attachment.key.toArrayBuffer()).then(function(decryptedBin) {
attachment.data = decryptedBin;
});
});
};
var promises = []; 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.group !== null) { var promises = [];
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... if (decrypted.group !== null) {
throw new Error("Sender was not a member of the group they were sending from"); 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);
switch(decrypted.group.type) { if (fromIndex < 0) {
case textsecure.protobuf.PushMessageContent.GroupContext.Type.UPDATE: //TODO: This could be indication of a race...
if (decrypted.group.avatar !== null) throw new Error("Sender was not a member of the group they were sending from");
promises.push(handleAttachment(decrypted.group.avatar)); }
if (existingGroup.filter(function(number) { decrypted.group.members.indexOf(number) < 0 }).length != 0) switch(decrypted.group.type) {
throw new Error("Attempted to remove numbers from group with an UPDATE"); case textsecure.protobuf.PushMessageContent.GroupContext.Type.UPDATE:
decrypted.group.added = decrypted.group.members.filter(function(number) { return existingGroup.indexOf(number) < 0; }); if (decrypted.group.avatar !== null)
promises.push(handleAttachment(decrypted.group.avatar));
var newGroup = textsecure.storage.groups.addNumbers(decrypted.group.id, decrypted.group.added); if (existingGroup.filter(function(number) { decrypted.group.members.indexOf(number) < 0 }).length != 0) {
if (newGroup.length != decrypted.group.members.length || throw new Error("Attempted to remove numbers from group with an UPDATE");
newGroup.filter(function(number) { return decrypted.group.members.indexOf(number) < 0; }).length != 0) }
throw new Error("Error calculating group member difference"); decrypted.group.added = decrypted.group.members.filter(function(number) { return existingGroup.indexOf(number) < 0; });
//TODO: Also follow this path if avatar + name haven't changed (ie we should start storing those) var newGroup = textsecure.storage.groups.addNumbers(decrypted.group.id, decrypted.group.added);
if (decrypted.group.avatar === null && decrypted.group.added.length == 0 && decrypted.group.name === null) if (newGroup.length != decrypted.group.members.length ||
return; newGroup.filter(function(number) { return decrypted.group.members.indexOf(number) < 0; }).length != 0) {
throw new Error("Error calculating group member difference");
//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) //TODO: Also follow this path if avatar + name haven't changed (ie we should start storing those)
promises.push(handleAttachment(decrypted.attachments[i])); if (decrypted.group.avatar === null && decrypted.group.added.length == 0 && decrypted.group.name === null) {
return Promise.all(promises).then(function() { return;
message_callback({pushMessage: proto, message: decrypted}); }
});
}) //TODO: Strictly verify all numbers (ie dont let verifyNumber do any user-magic tweaking)
}).catch(function(e) {
// TODO: Show "Invalid message" messages? decrypted.body = null;
console.log("Error handling incoming message: "); decrypted.attachments = [];
console.log(e);
}); 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) { window.textsecure.registerSingleDevice = function(number, verificationCode, stepDone) {
var signalingKey = textsecure.crypto.getRandomBytes(32 + 20); var signalingKey = textsecure.crypto.getRandomBytes(32 + 20);

View file

@ -362,9 +362,17 @@ window.textsecure.protocol = function() {
}); });
} }
var wipeIdentityAndTryMessageAgain = function(from, encodedMessage) { var wipeIdentityAndTryMessageAgain = function(from, encodedMessage, message_id) {
//TODO: Wipe identity key! // Wipe identity key!
return handlePreKeyWhisperMessage(from, encodedMessage); 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); textsecure.replay.registerFunction(wipeIdentityAndTryMessageAgain, textsecure.replay.Type.INIT_SESSION);