From d0fd3e94d8755fd4f49b29168661209670659f67 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Tue, 3 Jun 2014 12:39:29 -0400 Subject: [PATCH] sendMessage refactor, initial group stuff (breaks message storage) --- background.html | 2 + js/api.js | 34 +++++++ js/crypto.js | 26 ++++- js/fake_api.js | 3 +- js/helpers.js | 166 +++----------------------------- js/models/messages.js | 4 - js/models/threads.js | 55 ++++------- js/sendmessage.js | 216 ++++++++++++++++++++++++++++++++++++++++++ js/test.js | 38 +++----- js/views/messages.js | 2 +- options.html | 2 + popup.html | 3 +- test.html | 3 +- 13 files changed, 335 insertions(+), 219 deletions(-) create mode 100644 js/sendmessage.js diff --git a/background.html b/background.html index 16b537e77..305232f69 100644 --- a/background.html +++ b/background.html @@ -38,6 +38,8 @@ + + diff --git a/js/api.js b/js/api.js index 018dc883f..5c6860cb0 100644 --- a/js/api.js +++ b/js/api.js @@ -226,6 +226,40 @@ window.textsecure.api = function() { }); }; + self.putAttachment = function(encryptedBin) { + return doAjax({ + call : 'attachment', + httpType : 'GET', + do_auth : true, + }).then(function(response) { + return new Promise(function(resolve, reject) { + $.ajax(response.location, { + type : "PUT", + headers: { + "Content-Type": "application/octet-stream" + }, + data: encryptedBin, + + success : function() { + resolve(response.id); + }, + + error : function(jqXHR, textStatus, errorThrown) { + var code = jqXHR.status; + if (code > 999 || code < 100) + code = -1; + + var e = new Error(code); + e.name = "HTTPError"; + if (jqXHR.responseJSON) + e.response = jqXHR.responseJSON; + reject(e); + } + }); + }); + }); + }; + self.getWebsocket = function() { var user = textsecure.storage.getUnencrypted("number_id"); var password = textsecure.storage.getEncrypted("password"); diff --git a/js/crypto.js b/js/crypto.js index 75a07b20d..b88d3ab17 100644 --- a/js/crypto.js +++ b/js/crypto.js @@ -619,18 +619,38 @@ window.textsecure.crypto = function() { }); }; + self.encryptAttachment = function(plaintext, keys, iv) { + var aes_key = keys.slice(0, 32); + var mac_key = keys.slice(32, 64); + + return window.crypto.subtle.encrypt({name: "AES-CBC", iv: iv}, aes_key, plaintext).then(function(ciphertext) { + var ivAndCiphertext = new Uint8Array(16 + ciphertext.byteLength); + ivAndCiphertext.set(iv); + ivAndCiphertext.set(ciphertext, 16); + + return calculateMAC(ivAndCiphertext.buffer, mac_key).then(function(mac) { + var encryptedBin = new Uint8Array(16 + ciphertext.byteLength + 32); + encryptedBin.set(ivAndCiphertext.buffer); + encryptedBin.set(mac, 16 + ciphertext.byteLength); + return encryptedBin.buffer; + }); + }); + }; + self.handleIncomingPushMessageProto = function(proto) { switch(proto.type) { - case 0: //TYPE_MESSAGE_PLAINTEXT + case textsecure.protos.IncomingPushMessageProtobuf.Type.PLAINTEXT: return Promise.resolve(textsecure.protos.decodePushMessageContentProtobuf(getString(proto.message))); - case 1: //TYPE_MESSAGE_CIPHERTEXT + case textsecure.protos.IncomingPushMessageProtobuf.Type.CIPHERTEXT: var from = proto.source + "." + (proto.sourceDevice == null ? 0 : proto.sourceDevice); return decryptWhisperMessage(from, getString(proto.message)); - case 3: //TYPE_MESSAGE_PREKEY_BUNDLE + case textsecure.protos.IncomingPushMessageProtobuf.Type.PREKEY_BUNDLE: if (proto.message.readUint8() != (2 << 4 | 2)) throw new Error("Bad version byte"); var from = proto.source + "." + (proto.sourceDevice == null ? 0 : proto.sourceDevice); return handlePreKeyWhisperMessage(from, getString(proto.message)); + default: + return new Promise(function(resolve, reject) { reject(new Error("Unknown message type")); }); } } diff --git a/js/fake_api.js b/js/fake_api.js index 3c783a0a4..ecf09ecd6 100644 --- a/js/fake_api.js +++ b/js/fake_api.js @@ -33,7 +33,8 @@ textsecure.api.sendMessages = function(destination, messageArray) { msg.destinationRegistrationId === undefined || msg.body === undefined || msg.timestamp == undefined || - msg.relay !== undefined) + msg.relay !== undefined || + msg.destination !== undefined) throw new Error("Invalid message"); messagesSentMap[destination + "." + messageArray[i].destinationDeviceId] = msg; diff --git a/js/helpers.js b/js/helpers.js index 421c3650b..517fca2ef 100644 --- a/js/helpers.js +++ b/js/helpers.js @@ -406,6 +406,21 @@ window.textsecure.storage = function() { return self; }(); + /********************* + *** Group Storage *** + *********************/ + self.groups = function() { + var self = {}; + + //TODO + + self.getNumbers = function(groupId) { + return []; + } + + return self; + }(); + return self; }(); @@ -567,157 +582,6 @@ window.textsecure.subscribeToPush = function() { } }(); -// sendMessage(numbers = [], message = PushMessageContentProto, callback(success/failure map)) -window.textsecure.sendMessage = function() { - function getKeysForNumber(number, updateDevices) { - return textsecure.api.getKeysForNumber(number).then(function(response) { - var identityKey = getString(response[0].identityKey); - for (i in response) - if (getString(response[i].identityKey) != identityKey) - throw new Error("Identity key not consistent"); - - for (i in response) { - var updateDevice = (updateDevices === undefined); - if (!updateDevice) - for (j in updateDevices) - if (updateDevices[j] == response[i].deviceId) - updateDevice = true; - - if (updateDevice) - textsecure.storage.devices.saveDeviceObject({ - encodedNumber: number + "." + response[i].deviceId, - identityKey: response[i].identityKey, - publicKey: response[i].publicKey, - preKeyId: response[i].keyId, - registrationId: response[i].registrationId - }); - } - }); - } - - // success_callback(server success/failure map), error_callback(error_msg) - // message == PushMessageContentProto (NOT STRING) - function sendMessageToDevices(number, deviceObjectList, message, success_callback, error_callback) { - var jsonData = []; - var relay = undefined; - var promises = []; - - var addEncryptionFor = function(i) { - if (deviceObjectList[i].relay !== undefined) { - if (relay === undefined) - relay = deviceObjectList[i].relay; - else if (relay != deviceObjectList[i].relay) - return new Promise(function() { throw new Error("Mismatched relays for number " + number); }); - } else { - if (relay === undefined) - relay = ""; - else if (relay != "") - return new Promise(function() { throw new Error("Mismatched relays for number " + number); }); - } - - return textsecure.crypto.encryptMessageFor(deviceObjectList[i], message).then(function(encryptedMsg) { - jsonData[i] = { - type: encryptedMsg.type, - destination: number, - destinationDeviceId: textsecure.utils.unencodeNumber(deviceObjectList[i].encodedNumber)[1], - destinationRegistrationId: deviceObjectList[i].registrationId, - body: encryptedMsg.body, - timestamp: new Date().getTime() - }; - - if (deviceObjectList[i].relay !== undefined) - jsonData[i].relay = deviceObjectList[i].relay; - }); - } - - for (var i = 0; i < deviceObjectList.length; i++) - promises[i] = addEncryptionFor(i); - return Promise.all(promises).then(function() { - return textsecure.api.sendMessages(number, jsonData); - }); - } - - var tryMessageAgain = function(number, encodedMessage, callback) { - //TODO: Wipe identity key! - var message = textsecure.protos.decodePushMessageContentProtobuf(encodedMessage); - textsecure.sendMessage([number], message, callback); - } - textsecure.replay.registerReplayFunction(tryMessageAgain, textsecure.replay.SEND_MESSAGE); - - return function(numbers, message, callback) { - var numbersCompleted = 0; - var errors = []; - var successfulNumbers = []; - - var numberCompleted = function() { - numbersCompleted++; - if (numbersCompleted >= numbers.length) - callback({success: successfulNumbers, failure: errors}); - } - - var registerError = function(number, message, error) { - if (error.humanError) - message = error.humanError; - errors[errors.length] = { number: number, reason: message, error: error }; - numberCompleted(); - } - - var doSendMessage; - var reloadDevicesAndSend = function(number, recurse) { - return function() { - var devicesForNumber = textsecure.storage.devices.getDeviceObjectsForNumber(number); - if (devicesForNumber.length == 0) - registerError(number, "Go empty device list when loading device keys", null); - else - doSendMessage(number, devicesForNumber, recurse); - } - } - - doSendMessage = function(number, devicesForNumber, recurse) { - return sendMessageToDevices(number, devicesForNumber, message).then(function(result) { - successfulNumbers[successfulNumbers.length] = number; - numberCompleted(); - }).catch(function(error) { - if (error instanceof Error && error.name == "HTTPError" && (error.message == 410 || error.message == 409)) { - if (!recurse) - return registerError(number, "Hit retry limit attempting to reload device list", error); - - if (error.message == 409) - textsecure.storage.devices.removeDeviceIdsForNumber(number, error.response.extraDevices); - - var resetDevices = ((error.message == 410) ? error.response.staleDevices : error.response.missingDevices); - getKeysForNumber(number, resetDevices) - .then(reloadDevicesAndSend(number, false)) - .catch(function(error) { - if (error.message !== "Identity key changed") - registerError(number, "Failed to reload device keys", error); - else { - error = textsecure.replay.createReplayableError("The destination's identity key has changed", "The identity of the destination has changed. This may be malicious, or the destination may have simply reinstalled TextSecure.", - textsecure.replay.SEND_MESSAGE, [number, getString(message.encode())]); - registerError(number, "Identity key changed", error); - } - }); - } else - registerError(number, "Failed to create or send message", error); - }); - } - - for (var i = 0; i < numbers.length; i++) { - var number = numbers[i]; - var devicesForNumber = textsecure.storage.devices.getDeviceObjectsForNumber(number); - - if (devicesForNumber.length == 0) { - getKeysForNumber(number) - .then(reloadDevicesAndSend(number, true)) - .catch(function(error) { - registerError(number, "Failed to retreive new device keys for number " + number, error); - }); - } else - doSendMessage(number, devicesForNumber, true); - } - } -}(); - window.textsecure.register = function() { return function(number, verificationCode, singleDevice, stepDone) { var signalingKey = textsecure.crypto.getRandomBytes(32 + 20); diff --git a/js/models/messages.js b/js/models/messages.js index 76cae14b0..d46220a94 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -10,10 +10,6 @@ var Whisper = Whisper || {}; if (missing.length) { return "Message must have " + missing; } }, - toProto: function() { - return new textsecure.protos.PushMessageContentProtobuf({body: this.get('body')}); - }, - thread: function() { return Whisper.Threads.get(this.get('threadId')); } diff --git a/js/models/threads.js b/js/models/threads.js index d947e9e53..75c8aa1b8 100644 --- a/js/models/threads.js +++ b/js/models/threads.js @@ -13,27 +13,26 @@ var Whisper = Whisper || {}; }, validate: function(attributes, options) { - var required = ['id', 'type', 'recipients', 'timestamp', 'image', 'name']; + var required = ['id', 'type', 'timestamp', 'image', 'name']; var missing = _.filter(required, function(attr) { return !attributes[attr]; }); if (missing.length) { return "Thread must have " + missing; } - if (attributes.recipients.length === 0) { - return "No recipients for thread " + this.id; - } - for (var person in attributes.recipients) { - if (!person) return "Invalid recipient"; - } }, sendMessage: function(message) { - return new Promise(function(resolve) { - var m = Whisper.Messages.addOutgoingMessage(message, this); - textsecure.sendMessage(this.get('recipients'), m.toProto(), - function(result) { - console.log(result); - resolve(); - } - ); - }.bind(this)); + var m = Whisper.Messages.addOutgoingMessage(message, this); + if (this.get('type') == 'private') + var promise = textsecure.messaging.sendMessageToNumber(this.get('id'), message, []); + else + var promise = textsecure.messaging.sendMessageToGroup(this.get('id'), message, []); + promise.then( + function(result) { + console.log(result); + } + ).catch( + function(error) { + console.log(error); + } + ); }, messages: function() { @@ -51,23 +50,13 @@ var Whisper = Whisper || {}; return thread; }, - findOrCreateForRecipients: function(recipients) { + findOrCreateForRecipient: function(recipient) { var attributes = {}; - if (recipients.length > 1) { - attributes = { - //TODO group id formatting? - name : recipients, - recipients : recipients, - type : 'group', - }; - } else { - attributes = { - id : recipients[0], - name : recipients[0], - recipients : recipients, - type : 'private', - }; - } + attributes = { + id : recipient, + name : recipient, + type : 'private', + }; return this.findOrCreate(attributes); }, @@ -77,14 +66,12 @@ var Whisper = Whisper || {}; attributes = { id : decrypted.message.group.id, name : decrypted.message.group.name, - recipients : decrypted.message.group.members, type : 'group', }; } else { attributes = { id : decrypted.pushMessage.source, name : decrypted.pushMessage.source, - recipients : [decrypted.pushMessage.source], type : 'private' }; } diff --git a/js/sendmessage.js b/js/sendmessage.js new file mode 100644 index 000000000..f9267dc68 --- /dev/null +++ b/js/sendmessage.js @@ -0,0 +1,216 @@ +// sendMessage(numbers = [], message = PushMessageContentProto, callback(success/failure map)) +window.textsecure.messaging = function() { + var self = {}; + + function getKeysForNumber(number, updateDevices) { + return textsecure.api.getKeysForNumber(number).then(function(response) { + var identityKey = getString(response[0].identityKey); + for (i in response) + if (getString(response[i].identityKey) != identityKey) + throw new Error("Identity key not consistent"); + + for (i in response) { + var updateDevice = (updateDevices === undefined); + if (!updateDevice) + for (j in updateDevices) + if (updateDevices[j] == response[i].deviceId) + updateDevice = true; + + if (updateDevice) + textsecure.storage.devices.saveDeviceObject({ + encodedNumber: number + "." + response[i].deviceId, + identityKey: response[i].identityKey, + publicKey: response[i].publicKey, + preKeyId: response[i].keyId, + registrationId: response[i].registrationId + }); + } + }); + } + + // success_callback(server success/failure map), error_callback(error_msg) + // message == PushMessageContentProto (NOT STRING) + function sendMessageToDevices(number, deviceObjectList, message, success_callback, error_callback) { + var jsonData = []; + var relay = undefined; + var promises = []; + + var addEncryptionFor = function(i) { + if (deviceObjectList[i].relay !== undefined) { + if (relay === undefined) + relay = deviceObjectList[i].relay; + else if (relay != deviceObjectList[i].relay) + return new Promise(function() { throw new Error("Mismatched relays for number " + number); }); + } else { + if (relay === undefined) + relay = ""; + else if (relay != "") + return new Promise(function() { throw new Error("Mismatched relays for number " + number); }); + } + + return textsecure.crypto.encryptMessageFor(deviceObjectList[i], message).then(function(encryptedMsg) { + jsonData[i] = { + type: encryptedMsg.type, + destinationDeviceId: textsecure.utils.unencodeNumber(deviceObjectList[i].encodedNumber)[1], + destinationRegistrationId: deviceObjectList[i].registrationId, + body: encryptedMsg.body, + timestamp: new Date().getTime() + }; + + if (deviceObjectList[i].relay !== undefined) + jsonData[i].relay = deviceObjectList[i].relay; + }); + } + + for (var i = 0; i < deviceObjectList.length; i++) + promises[i] = addEncryptionFor(i); + return Promise.all(promises).then(function() { + return textsecure.api.sendMessages(number, jsonData); + }); + } + + var tryMessageAgain = function(number, encodedMessage, callback) { + //TODO: Wipe identity key! + var message = textsecure.protos.decodePushMessageContentProtobuf(encodedMessage); + textsecure.sendMessage([number], message, callback); + } + textsecure.replay.registerReplayFunction(tryMessageAgain, textsecure.replay.SEND_MESSAGE); + + var sendMessageProto = function(numbers, message, callback) { + var numbersCompleted = 0; + var errors = []; + var successfulNumbers = []; + + var numberCompleted = function() { + numbersCompleted++; + if (numbersCompleted >= numbers.length) + callback({success: successfulNumbers, failure: errors}); + } + + var registerError = function(number, message, error) { + if (error.humanError) + message = error.humanError; + errors[errors.length] = { number: number, reason: message, error: error }; + numberCompleted(); + } + + var doSendMessage; + var reloadDevicesAndSend = function(number, recurse) { + return function() { + var devicesForNumber = textsecure.storage.devices.getDeviceObjectsForNumber(number); + if (devicesForNumber.length == 0) + registerError(number, "Go empty device list when loading device keys", null); + else + doSendMessage(number, devicesForNumber, recurse); + } + } + + doSendMessage = function(number, devicesForNumber, recurse) { + return sendMessageToDevices(number, devicesForNumber, message).then(function(result) { + successfulNumbers[successfulNumbers.length] = number; + numberCompleted(); + }).catch(function(error) { + if (error instanceof Error && error.name == "HTTPError" && (error.message == 410 || error.message == 409)) { + if (!recurse) + return registerError(number, "Hit retry limit attempting to reload device list", error); + + if (error.message == 409) + textsecure.storage.devices.removeDeviceIdsForNumber(number, error.response.extraDevices); + + var resetDevices = ((error.message == 410) ? error.response.staleDevices : error.response.missingDevices); + getKeysForNumber(number, resetDevices) + .then(reloadDevicesAndSend(number, false)) + .catch(function(error) { + if (error.message !== "Identity key changed") + registerError(number, "Failed to reload device keys", error); + else { + error = textsecure.replay.createReplayableError("The destination's identity key has changed", "The identity of the destination has changed. This may be malicious, or the destination may have simply reinstalled TextSecure.", + textsecure.replay.SEND_MESSAGE, [number, getString(message.encode())]); + registerError(number, "Identity key changed", error); + } + }); + } else + registerError(number, "Failed to create or send message", error); + }); + } + + for (var i = 0; i < numbers.length; i++) { + var number = numbers[i]; + var devicesForNumber = textsecure.storage.devices.getDeviceObjectsForNumber(number); + + if (devicesForNumber.length == 0) { + getKeysForNumber(number) + .then(reloadDevicesAndSend(number, true)) + .catch(function(error) { + registerError(number, "Failed to retreive new device keys for number " + number, error); + }); + } else + doSendMessage(number, devicesForNumber, true); + } + } + + var makeAttachmentPointer = function(attachment) { + var proto = new textsecure.protos.PushMessageContentProtobuf.AttachmentPointer(); + proto.key = textsecure.crypto.getRandomBytes(64); + + var iv = textsecure.crypto.getRandomBytes(16); + return textsecure.crypto.encryptAttachment(attachment.data, proto.key, iv).then(function(encryptedBin) { + return textsecure.api.putAttachment(encryptedBin).then(function(id) { + proto.id = id; + proto.contentType = attachment.contentType; + return proto; + }); + }); + } + + self.sendMessageToNumber = function(number, messageText, attachments) { + return new Promise(function(resolve, reject) { + var proto = new textsecure.protos.PushMessageContentProtobuf(); + proto.body = messageText; + + var promises = []; + for (i in attachments) + promises.push(makeAttachmentPointer(attachments[i])); + Promise.all(promises).then(function(attachmentsArray) { + proto.attachments = attachmentsArray; + sendMessageProto([number], proto, function(res) { + if (res.failure.length > 0) + reject(res.failure[0].error); + else + resolve(); + }); + }); + }); + } + + self.sendMessageToGroup = function(groupId, messageText, attachments) { + return new Promise(function(resolve, reject) { + var proto = new textsecure.protos.PushMessageContentProtobuf(); + proto.body = messageText; + proto.group = new textsecure.protos.PushMessageContentProtobuf.GroupContext(); + proto.group.id = groupId; + proto.group.type = textsecure.protos.PushMessageContentProtobuf.GroupContext.DELIVER; + + var numbers = textsecure.storage.groups.getNumbers(groupId); + + var promises = []; + for (i in attachments) + promises.push(makeAttachmentPointer(attachments[i])); + Promise.all(promises).then(function(attachmentsArray) { + proto.attachments = attachmentsArray; + sendMessageProto(numbers, proto, function(res) { + if (res.failure.length > 0) { + reject(res.failure); + } else + resolve(); + }); + }); + }); + } + + self.closeSession = function(number) { + //TODO + } + + return self; +}(); diff --git a/js/test.js b/js/test.js index 7caf66a1d..a7847315d 100644 --- a/js/test.js +++ b/js/test.js @@ -114,7 +114,7 @@ textsecure.registerOnLoadFunction(function() { var text_message = new PushMessageProto(); text_message.body = "Hi Mom"; - var server_message = {type: 0, // unencrypted + var server_message = {type: 4, // unencrypted source: "+19999999999", timestamp: 42, message: text_message.encode() }; return textsecure.crypto.handleIncomingPushMessageProto(server_message).then(function(message) { @@ -367,28 +367,20 @@ textsecure.registerOnLoadFunction(function() { if (data.getKeys !== undefined) getKeysForNumberMap[remoteNumber] = data.getKeys; - var message = new textsecure.protos.PushMessageContentProtobuf(); - message.body = data.smsText; - - return new Promise(function(resolve) { - textsecure.sendMessage([remoteNumber], message, function(res) { - if (res.failure.length != 0 || res.success.length != 1 || res.success[0] != remoteNumber) - return resolve(false); - - var msg = messagesSentMap[remoteNumber + "." + 0]; - delete messagesSentMap[remoteNumber + "." + 0]; - //XXX: This should be all we do: stepDone(getString(data.expectedCiphertext) == getString(res.body)); - if (msg.type == 1) { - var expectedString = getString(data.expectedCiphertext); - var decoded = textsecure.protos.decodeWhisperMessageProtobuf(expectedString.substring(1, expectedString.length - 8)); - var result = getString(msg.body); - resolve(getString(decoded.encode()) == result.substring(1, result.length - 8)); - } else { - var decoded = textsecure.protos.decodePreKeyWhisperMessageProtobuf(getString(data.expectedCiphertext).substr(1)); - var result = getString(msg.body).substring(1); - resolve(getString(decoded.encode()) == result); - } - }); + return textsecure.messaging.sendMessageToNumber(remoteNumber, data.smsText, []).then(function() { + var msg = messagesSentMap[remoteNumber + "." + 0]; + delete messagesSentMap[remoteNumber + "." + 0]; + //XXX: This should be all we do: stepDone(getString(data.expectedCiphertext) == getString(res.body)); + if (msg.type == 1) { + var expectedString = getString(data.expectedCiphertext); + var decoded = textsecure.protos.decodeWhisperMessageProtobuf(expectedString.substring(1, expectedString.length - 8)); + var result = getString(msg.body); + return getString(decoded.encode()) == result.substring(1, result.length - 8); + } else { + var decoded = textsecure.protos.decodePreKeyWhisperMessageProtobuf(getString(data.expectedCiphertext).substr(1)); + var result = getString(msg.body).substring(1); + return getString(decoded.encode()) == result; + } }); } diff --git a/js/views/messages.js b/js/views/messages.js index d5fcdd6a2..baba32d69 100644 --- a/js/views/messages.js +++ b/js/views/messages.js @@ -51,7 +51,7 @@ var Whisper = Whisper || {}; numbers = _.filter(numbers, _.identity); // rm undefined, null, "", etc... if (numbers.length) { $('#send').hide(); - Whisper.Threads.findOrCreateForRecipients(numbers).trigger('select'); + Whisper.Threads.findOrCreateForRecipient(numbers).trigger('select'); } else { Whisper.notify('recipient missing or invalid'); $('#send input[type=text]').focus(); diff --git a/options.html b/options.html index 328273507..71d1b140a 100644 --- a/options.html +++ b/options.html @@ -66,6 +66,8 @@ + + diff --git a/popup.html b/popup.html index 26e033cd4..f2ccd6a93 100644 --- a/popup.html +++ b/popup.html @@ -68,8 +68,9 @@ - + + diff --git a/test.html b/test.html index 81b94cbd7..15ef4da68 100644 --- a/test.html +++ b/test.html @@ -48,8 +48,9 @@ - + +