From 8ad1a38b5b7d399bd9ef3a93446da8b32e30e3c1 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Tue, 13 Jan 2015 14:09:32 -1000 Subject: [PATCH] Move js files around for libtextsecure split --- Gruntfile.js | 19 + background.html | 20 +- index.html | 23 +- js/libtextsecure.js | 2723 ++++++++++++++++++ {js => libtextsecure}/api.js | 0 {js => libtextsecure}/crypto.js | 0 {js => libtextsecure}/errors.js | 0 {js => libtextsecure}/helpers.js | 0 {js => libtextsecure}/protobufs.js | 0 {js => libtextsecure}/protocol.js | 0 {js => libtextsecure}/sendmessage.js | 0 {js => libtextsecure}/storage.js | 0 {js => libtextsecure}/storage/devices.js | 0 {js => libtextsecure}/storage/groups.js | 0 {js => libtextsecure}/stringview.js | 0 {js => libtextsecure}/websocket-resources.js | 0 {js => libtextsecure}/websocket.js | 0 options.html | 20 +- test/index.html | 21 +- 19 files changed, 2764 insertions(+), 62 deletions(-) create mode 100644 js/libtextsecure.js rename {js => libtextsecure}/api.js (100%) rename {js => libtextsecure}/crypto.js (100%) rename {js => libtextsecure}/errors.js (100%) rename {js => libtextsecure}/helpers.js (100%) rename {js => libtextsecure}/protobufs.js (100%) rename {js => libtextsecure}/protocol.js (100%) rename {js => libtextsecure}/sendmessage.js (100%) rename {js => libtextsecure}/storage.js (100%) rename {js => libtextsecure}/storage/devices.js (100%) rename {js => libtextsecure}/storage/groups.js (100%) rename {js => libtextsecure}/stringview.js (100%) rename {js => libtextsecure}/websocket-resources.js (100%) rename {js => libtextsecure}/websocket.js (100%) diff --git a/Gruntfile.js b/Gruntfile.js index d9ea8ba9b..05ede6716 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -53,6 +53,25 @@ module.exports = function(grunt) { 'test/_test.js' ], dest: 'test/test.js', + }, + libtextsecure: { + src: [ + 'libtextsecure/protobufs.js', + 'libtextsecure/websocket.js', + 'libtextsecure/websocket-resources.js', + 'libtextsecure/helpers.js', + 'libtextsecure/errors.js', + 'libtextsecure/stringview.js', + 'libtextsecure/storage.js', + 'libtextsecure/storage/devices.js', + 'libtextsecure/storage/groups.js', + 'libtextsecure/api.js', + 'libtextsecure/crypto.js', + 'libtextsecure/protocol.js', + 'libtextsecure/sendmessage.js', + ], + dest: 'js/libtextsecure.js', + }, } }, sass: { diff --git a/background.html b/background.html index 847e6e3b6..0049bce40 100644 --- a/background.html +++ b/background.html @@ -16,27 +16,17 @@ - - - - - - - - - - - - - + + + + + - - diff --git a/index.html b/index.html index e81c946aa..5d00483b7 100644 --- a/index.html +++ b/index.html @@ -130,31 +130,20 @@ + + + + + + - - - - - - - - - - - - - - - - - diff --git a/js/libtextsecure.js b/js/libtextsecure.js new file mode 100644 index 000000000..01e72938a --- /dev/null +++ b/js/libtextsecure.js @@ -0,0 +1,2723 @@ +;(function() { + + function loadProtoBufs(filename) { + return dcodeIO.ProtoBuf.loadProtoFile({root: 'protos', file: filename}).build('textsecure'); + }; + + var pushMessages = loadProtoBufs('IncomingPushMessageSignal.proto'); + var protocolMessages = loadProtoBufs('WhisperTextProtocol.proto'); + var subProtocolMessages = loadProtoBufs('SubProtocol.proto'); + var deviceMessages = loadProtoBufs('DeviceMessages.proto'); + + window.textsecure = window.textsecure || {}; + window.textsecure.protobuf = { + IncomingPushMessageSignal : pushMessages.IncomingPushMessageSignal, + PushMessageContent : pushMessages.PushMessageContent, + WhisperMessage : protocolMessages.WhisperMessage, + PreKeyWhisperMessage : protocolMessages.PreKeyWhisperMessage, + DeviceInit : deviceMessages.DeviceInit, + IdentityKey : deviceMessages.IdentityKey, + DeviceControl : deviceMessages.DeviceControl, + WebSocketResponseMessage : subProtocolMessages.WebSocketResponseMessage, + WebSocketRequestMessage : subProtocolMessages.WebSocketRequestMessage, + WebSocketMessage : subProtocolMessages.WebSocketMessage + }; +})(); + +/* vim: ts=4:sw=4:expandtab + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ +;(function(){ + 'use strict'; + + /* + * var socket = textsecure.websocket(url); + * + * Returns an adamantium-reinforced super socket, capable of sending + * app-level keep alives and automatically reconnecting. + * + */ + + window.textsecure.websocket = function (url) { + var socketWrapper = { + onmessage : function() {}, + ondisconnect : function() {}, + }; + var socket; + var keepAliveTimer; + var reconnectSemaphore = 0; + var reconnectTimeout = 1000; + + function resetKeepAliveTimer() { + clearTimeout(keepAliveTimer); + keepAliveTimer = setTimeout(function() { + socket.send( + new textsecure.protobuf.WebSocketMessage({ + type: textsecure.protobuf.WebSocketMessage.Type.REQUEST, + request: { verb: 'GET', path: '/v1/keepalive' } + }).encode().toArrayBuffer() + ); + + resetKeepAliveTimer(); + }, 15000); + }; + + function reconnect(e) { + reconnectSemaphore--; + setTimeout(connect, reconnectTimeout); + socketWrapper.ondisconnect(e); + }; + + function connect() { + clearTimeout(keepAliveTimer); + if (++reconnectSemaphore <= 0) { return; } + + if (socket) { socket.close(); } + socket = new WebSocket(url); + + socket.onerror = reconnect; + socket.onclose = reconnect; + socket.onopen = resetKeepAliveTimer; + + socket.onmessage = function(response) { + socketWrapper.onmessage(response); + resetKeepAliveTimer(); + }; + + socketWrapper.send = function(msg) { + socket.send(msg); + } + } + + connect(); + return socketWrapper; + }; +})(); + +/* vim: ts=4:sw=4:expandtab + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ +;(function(){ + 'use strict'; + + /* + * WebSocket-Resources + * + * Create a request-response interface over websockets using the + * WebSocket-Resources sub-protocol[1]. + * + * var client = new WebSocketResource(socket, function(request) { + * request.respond(200, 'OK'); + * }); + * + * client.sendRequest({ + * verb: 'PUT', + * path: '/v1/messages', + * body: '{ some: "json" }', + * success: function(message, status, request) {...}, + * error: function(message, status, request) {...} + * }); + * + * 1. https://github.com/WhisperSystems/WebSocket-Resources + * + */ + + var Request = function(options) { + this.verb = options.verb || options.type; + this.path = options.path || options.url; + this.body = options.body || options.data; + this.success = options.success + this.error = options.error + this.id = options.id; + + if (this.id === undefined) { + var bits = new Uint32Array(2); + window.crypto.getRandomValues(bits); + this.id = dcodeIO.Long.fromBits(bits[0], bits[1], true); + } + }; + + var IncomingWebSocketRequest = function(options) { + var request = new Request(options); + var socket = options.socket; + + this.verb = request.verb; + this.path = request.path; + this.body = request.body; + + this.respond = function(status, message) { + socket.send( + new textsecure.protobuf.WebSocketMessage({ + type: textsecure.protobuf.WebSocketMessage.Type.RESPONSE, + response: { id: request.id, message: message, status: status } + }).encode().toArrayBuffer() + ); + }; + }; + + var outgoing = {}; + var OutgoingWebSocketRequest = function(options, socket) { + var request = new Request(options); + outgoing[request.id] = request; + socket.send( + new textsecure.protobuf.WebSocketMessage({ + type: textsecure.protobuf.WebSocketMessage.Type.REQUEST, + request: { + verb : request.verb, + path : request.path, + body : request.body, + id : request.id + } + }).encode().toArrayBuffer() + ); + }; + + window.WebSocketResource = function(socket, handleRequest) { + this.sendRequest = function(options) { + return new OutgoingWebSocketRequest(options, socket); + }; + + socket.onmessage = function(socketMessage) { + var blob = socketMessage.data; + var reader = new FileReader(); + reader.onload = function() { + var message = textsecure.protobuf.WebSocketMessage.decode(reader.result); + if (message.type === textsecure.protobuf.WebSocketMessage.Type.REQUEST ) { + handleRequest( + new IncomingWebSocketRequest({ + verb : message.request.verb, + path : message.request.path, + body : message.request.body, + id : message.request.id, + socket : socket + }) + ); + } + else if (message.type === textsecure.protobuf.WebSocketMessage.Type.RESPONSE ) { + var response = message.response; + var request = outgoing[response.id]; + if (request) { + request.response = response; + var callback = request.error; + if (response.status >= 200 && response.status < 300) { + callback = request.success; + } + + if (typeof callback === 'function') { + callback(response.message, response.status, request); + } + } else { + throw 'Received response for unknown request ' + message.response.id; + } + } + }; + reader.readAsArrayBuffer(blob); + }; + }; + +}()); + +/* vim: ts=4:sw=4:expandtab + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +window.textsecure = window.textsecure || {}; + +/********************************* + *** Type conversion utilities *** + *********************************/ +// Strings/arrays +//TODO: Throw all this shit in favor of consistent types +//TODO: Namespace +var StaticByteBufferProto = new dcodeIO.ByteBuffer().__proto__; +var StaticArrayBufferProto = new ArrayBuffer().__proto__; +var StaticUint8ArrayProto = new Uint8Array().__proto__; +function getString(thing) { + if (thing === Object(thing)) { + if (thing.__proto__ == StaticUint8ArrayProto) + return String.fromCharCode.apply(null, thing); + if (thing.__proto__ == StaticArrayBufferProto) + return getString(new Uint8Array(thing)); + if (thing.__proto__ == StaticByteBufferProto) + return thing.toString("binary"); + } + return thing; +} + +function getStringable(thing) { + return (typeof thing == "string" || typeof thing == "number" || typeof thing == "boolean" || + (thing === Object(thing) && + (thing.__proto__ == StaticArrayBufferProto || + thing.__proto__ == StaticUint8ArrayProto || + thing.__proto__ == StaticByteBufferProto))); +} + +function isEqual(a, b, mayBeShort) { + // TODO: Special-case arraybuffers, etc + if (a === undefined || b === undefined) + return false; + a = getString(a); + b = getString(b); + var maxLength = mayBeShort ? Math.min(a.length, b.length) : Math.max(a.length, b.length); + if (maxLength < 5) + throw new Error("a/b compare too short"); + return a.substring(0, Math.min(maxLength, a.length)) == b.substring(0, Math.min(maxLength, b.length)); +} + +function toArrayBuffer(thing) { + //TODO: Optimize this for specific cases + if (thing === undefined) + return undefined; + if (thing === Object(thing) && thing.__proto__ == StaticArrayBufferProto) + return thing; + + if (thing instanceof Array) { + // Assuming Uint16Array from curve25519 + var res = new ArrayBuffer(thing.length * 2); + var uint = new Uint16Array(res); + for (var i = 0; i < thing.length; i++) + uint[i] = thing[i]; + return res; + } + + if (!getStringable(thing)) + throw new Error("Tried to convert a non-stringable thing of type " + typeof thing + " to an array buffer"); + var str = getString(thing); + var res = new ArrayBuffer(str.length); + var uint = new Uint8Array(res); + for (var i = 0; i < str.length; i++) + uint[i] = str.charCodeAt(i); + return res; +} + +// Number formatting utils +window.textsecure.utils = function() { + var self = {}; + self.unencodeNumber = function(number) { + return number.split("."); + }; + + self.isNumberSane = function(number) { + return number[0] == "+" && + /^[0-9]+$/.test(number.substring(1)); + } + + /************************** + *** JSON'ing Utilities *** + **************************/ + function ensureStringed(thing) { + if (getStringable(thing)) + return getString(thing); + else if (thing instanceof Array) { + var res = []; + for (var i = 0; i < thing.length; i++) + res[i] = ensureStringed(thing[i]); + return res; + } else if (thing === Object(thing)) { + var res = {}; + for (var key in thing) + res[key] = ensureStringed(thing[key]); + return res; + } + throw new Error("unsure of how to jsonify object of type " + typeof thing); + + } + + self.jsonThing = function(thing) { + return JSON.stringify(ensureStringed(thing)); + } + + return self; +}(); + +window.textsecure.throwHumanError = function(error, type, humanError) { + var e = new Error(error); + if (type !== undefined) + e.name = type; + e.humanError = humanError; + throw e; +} + +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, source) { + + // 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 & textsecure.protobuf.PushMessageContent.Flags.END_SESSION) + == textsecure.protobuf.PushMessageContent.Flags.END_SESSION) + return; + if (decrypted.flags != 0) { + throw new Error("Unknown flags in message"); + } + + 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); + if (decrypted.group.avatar !== null) { + promises.push(handleAttachment(decrypted.group.avatar)); + } + } else { + var fromIndex = existingGroup.indexOf(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 (decrypted.group.members.filter(function(number) { return !textsecure.utils.isNumberSane(number); }).length != 0) + throw new Error("Invalid number in new group members"); + + 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; + } + + decrypted.body = null; + decrypted.attachments = []; + + break; + case textsecure.protobuf.PushMessageContent.GroupContext.Type.QUIT: + textsecure.storage.groups.removeNumber(decrypted.group.id, 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); + textsecure.storage.putEncrypted('signaling_key', signalingKey); + + var password = btoa(getString(textsecure.crypto.getRandomBytes(16))); + password = password.substring(0, password.length - 2); + textsecure.storage.putEncrypted("password", password); + + var registrationId = new Uint16Array(textsecure.crypto.getRandomBytes(2))[0]; + registrationId = registrationId & 0x3fff; + textsecure.storage.putUnencrypted("registrationId", registrationId); + + return textsecure.api.confirmCode(number, verificationCode, password, signalingKey, registrationId, true).then(function() { + var numberId = number + ".1"; + textsecure.storage.putUnencrypted("number_id", numberId); + textsecure.storage.putUnencrypted("regionCode", libphonenumber.util.getRegionCodeForNumber(number)); + stepDone(1); + + return textsecure.protocol.generateKeys().then(function(keys) { + stepDone(2); + return textsecure.api.registerKeys(keys).then(function() { + stepDone(3); + }); + }); + }); +} + +window.textsecure.registerSecondDevice = function(encodedDeviceInit, cryptoInfo, stepDone) { + var deviceInit = textsecure.protobuf.DeviceInit.decode(encodedDeviceInit, 'binary'); + return cryptoInfo.decryptAndHandleDeviceInit(deviceInit).then(function(identityKey) { + if (identityKey.server != textsecure.api.relay) + throw new Error("Unknown relay used by master"); + var number = identityKey.phoneNumber; + + stepDone(1); + + var signalingKey = textsecure.crypto.getRandomBytes(32 + 20); + textsecure.storage.putEncrypted('signaling_key', signalingKey); + + var password = btoa(getString(textsecure.crypto.getRandomBytes(16))); + password = password.substring(0, password.length - 2); + textsecure.storage.putEncrypted("password", password); + + var registrationId = new Uint16Array(textsecure.crypto.getRandomBytes(2))[0]; + registrationId = registrationId & 0x3fff; + textsecure.storage.putUnencrypted("registrationId", registrationId); + + return textsecure.api.confirmCode(number, identityKey.provisioningCode, password, signalingKey, registrationId, false).then(function(result) { + var numberId = number + "." + result; + textsecure.storage.putUnencrypted("number_id", numberId); + textsecure.storage.putUnencrypted("regionCode", libphonenumber.util.getRegion(number)); + stepDone(2); + + return textsecure.protocol.generateKeys().then(function(keys) { + stepDone(3); + return textsecure.api.registerKeys(keys).then(function() { + stepDone(4); + //TODO: Send DeviceControl.NEW_DEVICE_REGISTERED to all other devices + }); + }); + }); + }); +}; + +/* vim: ts=4:sw=4:expandtab + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +;(function() { + 'use strict'; + + var registeredFunctions = {}; + var Type = { + SEND_MESSAGE: 1, + INIT_SESSION: 2, + }; + window.textsecure = window.textsecure || {}; + window.textsecure.replay = { + Type: Type, + registerFunction: function(func, functionCode) { + registeredFunctions[functionCode] = func; + } + }; + + function ReplayableError(options) { + options = options || {}; + this.name = options.name || 'ReplayableError'; + this.functionCode = options.functionCode; + this.args = options.args; + } + ReplayableError.prototype = new Error(); + ReplayableError.prototype.constructor = ReplayableError; + + ReplayableError.prototype.replay = function() { + var args = Array.prototype.slice.call(arguments); + args.shift(); + args = this.args.concat(args); + + registeredFunctions[this.functionCode].apply(window, args); + }; + + function IncomingIdentityKeyError(number, message) { + ReplayableError.call(this, { + functionCode : Type.INIT_SESSION, + args : [number, message] + }); + this.name = 'IncomingIdentityKeyError'; + this.message = "The identity of the sender has changed. This may be malicious, or the sender may have simply reinstalled TextSecure."; + } + IncomingIdentityKeyError.prototype = new ReplayableError(); + IncomingIdentityKeyError.prototype.constructor = IncomingIdentityKeyError; + + function OutgoingIdentityKeyError(number, message) { + ReplayableError.call(this, { + functionCode : Type.SEND_MESSAGE, + args : [number, message] + }); + this.name = 'OutgoingIdentityKeyError'; + this.message = "The identity of the destination has changed. This may be malicious, or the destination may have simply reinstalled TextSecure."; + } + OutgoingIdentityKeyError.prototype = new ReplayableError(); + OutgoingIdentityKeyError.prototype.constructor = OutgoingIdentityKeyError; + + window.textsecure.IncomingIdentityKeyError = IncomingIdentityKeyError; + window.textsecure.OutgoingIdentityKeyError = OutgoingIdentityKeyError; + window.textsecure.ReplayableError = ReplayableError; + +})(); + +/* vim: ts=4:sw=4:expandtab + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ +;(function() { + "use strict"; + + window.StringView = { + + /* + * These functions from the Mozilla Developer Network + * and have been placed in the public domain. + * https://developer.mozilla.org/en-US/docs/Web/API/WindowBase64/Base64_encoding_and_decoding + * https://developer.mozilla.org/en-US/docs/MDN/About#Copyrights_and_licenses + */ + + b64ToUint6: function(nChr) { + return nChr > 64 && nChr < 91 ? + nChr - 65 + : nChr > 96 && nChr < 123 ? + nChr - 71 + : nChr > 47 && nChr < 58 ? + nChr + 4 + : nChr === 43 ? + 62 + : nChr === 47 ? + 63 + : + 0; + }, + + base64ToBytes: function(sBase64, nBlocksSize) { + var + sB64Enc = sBase64.replace(/[^A-Za-z0-9\+\/]/g, ""), nInLen = sB64Enc.length, + nOutLen = nBlocksSize ? Math.ceil((nInLen * 3 + 1 >> 2) / nBlocksSize) * nBlocksSize : nInLen * 3 + 1 >> 2; + var aBBytes = new ArrayBuffer(nOutLen); + var taBytes = new Uint8Array(aBBytes); + + for (var nMod3, nMod4, nUint24 = 0, nOutIdx = 0, nInIdx = 0; nInIdx < nInLen; nInIdx++) { + nMod4 = nInIdx & 3; + nUint24 |= StringView.b64ToUint6(sB64Enc.charCodeAt(nInIdx)) << 18 - 6 * nMod4; + if (nMod4 === 3 || nInLen - nInIdx === 1) { + for (nMod3 = 0; nMod3 < 3 && nOutIdx < nOutLen; nMod3++, nOutIdx++) { + taBytes[nOutIdx] = nUint24 >>> (16 >>> nMod3 & 24) & 255; + } + nUint24 = 0; + } + } + return aBBytes; + }, + + uint6ToB64: function(nUint6) { + return nUint6 < 26 ? + nUint6 + 65 + : nUint6 < 52 ? + nUint6 + 71 + : nUint6 < 62 ? + nUint6 - 4 + : nUint6 === 62 ? + 43 + : nUint6 === 63 ? + 47 + : + 65; + }, + + bytesToBase64: function(aBytes) { + var nMod3, sB64Enc = ""; + for (var nLen = aBytes.length, nUint24 = 0, nIdx = 0; nIdx < nLen; nIdx++) { + nMod3 = nIdx % 3; + if (nIdx > 0 && (nIdx * 4 / 3) % 76 === 0) { sB64Enc += "\r\n"; } + nUint24 |= aBytes[nIdx] << (16 >>> nMod3 & 24); + if (nMod3 === 2 || aBytes.length - nIdx === 1) { + sB64Enc += String.fromCharCode( + StringView.uint6ToB64(nUint24 >>> 18 & 63), + StringView.uint6ToB64(nUint24 >>> 12 & 63), + StringView.uint6ToB64(nUint24 >>> 6 & 63), + StringView.uint6ToB64(nUint24 & 63) + ); + nUint24 = 0; + } + } + return sB64Enc.replace(/A(?=A$|$)/g, "="); + } + }; +}()); + +/* vim: ts=4:sw=4 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +'use strict'; + +;(function() { + + /************************************************ + *** Utilities to store data in local storage *** + ************************************************/ + window.textsecure = window.textsecure || {}; + window.textsecure.storage = window.textsecure.storage || {}; + + window.textsecure.storage = { + + /***************************** + *** Base Storage Routines *** + *****************************/ + putEncrypted: function(key, value) { + //TODO + if (value === undefined) + throw new Error("Tried to store undefined"); + localStorage.setItem("e" + key, textsecure.utils.jsonThing(value)); + }, + + getEncrypted: function(key, defaultValue) { + //TODO + var value = localStorage.getItem("e" + key); + if (value === null) + return defaultValue; + return JSON.parse(value); + }, + + removeEncrypted: function(key) { + localStorage.removeItem("e" + key); + }, + + putUnencrypted: function(key, value) { + if (value === undefined) + throw new Error("Tried to store undefined"); + localStorage.setItem("u" + key, textsecure.utils.jsonThing(value)); + }, + + getUnencrypted: function(key, defaultValue) { + var value = localStorage.getItem("u" + key); + if (value === null) + return defaultValue; + return JSON.parse(value); + }, + + removeUnencrypted: function(key) { + localStorage.removeItem("u" + key); + } + }; +})(); + + +/* vim: ts=4:sw=4 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +'use strict'; + +;(function() { + /********************** + *** Device Storage *** + **********************/ + window.textsecure = window.textsecure || {}; + window.textsecure.storage = window.textsecure.storage || {}; + + window.textsecure.storage.devices = { + saveDeviceObject: function(deviceObject) { + return internalSaveDeviceObject(deviceObject, false); + }, + + saveKeysToDeviceObject: function(deviceObject) { + return internalSaveDeviceObject(deviceObject, true); + }, + + getDeviceObjectsForNumber: function(number) { + var map = textsecure.storage.getEncrypted("devices" + number); + return map === undefined ? [] : map.devices; + }, + + getDeviceObject: function(encodedNumber) { + var number = textsecure.utils.unencodeNumber(encodedNumber); + var devices = textsecure.storage.devices.getDeviceObjectsForNumber(number[0]); + if (devices === undefined) + return undefined; + + for (var i in devices) + if (devices[i].encodedNumber == encodedNumber) + return devices[i]; + + return undefined; + }, + + removeDeviceIdsForNumber: function(number, deviceIdsToRemove) { + var map = textsecure.storage.getEncrypted("devices" + number); + if (map === undefined) + throw new Error("Tried to remove device for unknown number"); + + var newDevices = []; + var devicesRemoved = 0; + for (var i in map.devices) { + var keep = true; + for (var j in deviceIdsToRemove) + if (map.devices[i].encodedNumber == number + "." + deviceIdsToRemove[j]) + keep = false; + + if (keep) + newDevices.push(map.devices[i]); + else + devicesRemoved++; + } + + if (devicesRemoved != deviceIdsToRemove.length) + throw new Error("Tried to remove unknown device"); + } + }; + + var internalSaveDeviceObject = function(deviceObject, onlyKeys) { + if (deviceObject.identityKey === undefined || deviceObject.encodedNumber === undefined) + throw new Error("Tried to store invalid deviceObject"); + + var number = textsecure.utils.unencodeNumber(deviceObject.encodedNumber)[0]; + var map = textsecure.storage.getEncrypted("devices" + number); + + if (map === undefined) + map = { devices: [deviceObject], identityKey: deviceObject.identityKey }; + else if (map.identityKey != getString(deviceObject.identityKey)) + throw new Error("Identity key changed"); + else { + var updated = false; + for (var i in map.devices) { + if (map.devices[i].encodedNumber == deviceObject.encodedNumber) { + if (!onlyKeys) + map.devices[i] = deviceObject; + else { + map.devices[i].preKey = deviceObject.preKey; + map.devices[i].preKeyId = deviceObject.preKeyId; + map.devices[i].signedKey = deviceObject.signedKey; + map.devices[i].signedKeyId = deviceObject.signedKeyId; + map.devices[i].registrationId = deviceObject.registrationId; + } + updated = true; + } + } + + if (!updated) + map.devices.push(deviceObject); + } + + textsecure.storage.putEncrypted("devices" + number, map); + }; +})(); + +/* vim: ts=4:sw=4 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +'use strict'; + +;(function() { + /********************* + *** Group Storage *** + *********************/ + window.textsecure = window.textsecure || {}; + window.textsecure.storage = window.textsecure.storage || {}; + + window.textsecure.storage.groups = { + getGroupListForNumber: function(number) { + return textsecure.storage.getEncrypted("groupMembership" + number, []); + }, + + createNewGroup: function(numbers, groupId) { + if (groupId !== undefined && textsecure.storage.getEncrypted("group" + groupId) !== undefined) { + throw new Error("Tried to recreate group"); + } + + while (groupId === undefined || textsecure.storage.getEncrypted("group" + groupId) !== undefined) { + groupId = getString(textsecure.crypto.getRandomBytes(16)); + } + + var me = textsecure.utils.unencodeNumber(textsecure.storage.getUnencrypted("number_id"))[0]; + var haveMe = false; + var finalNumbers = []; + for (var i in numbers) { + var number = numbers[i]; + if (!textsecure.utils.isNumberSane(number)) + throw new Error("Invalid number in group"); + if (number == me) + haveMe = true; + if (finalNumbers.indexOf(number) < 0) { + finalNumbers.push(number); + addGroupToNumber(groupId, number); + } + } + + if (!haveMe) + finalNumbers.push(me); + + textsecure.storage.putEncrypted("group" + groupId, {numbers: finalNumbers}); + + return {id: groupId, numbers: finalNumbers}; + }, + + getNumbers: function(groupId) { + var group = textsecure.storage.getEncrypted("group" + groupId); + if (group === undefined) + return undefined; + + return group.numbers; + }, + + removeNumber: function(groupId, number) { + var group = textsecure.storage.getEncrypted("group" + groupId); + if (group === undefined) + return undefined; + + var me = textsecure.utils.unencodeNumber(textsecure.storage.getUnencrypted("number_id"))[0]; + if (number == me) + throw new Error("Cannot remove ourselves from a group, leave the group instead"); + + var i = group.numbers.indexOf(number); + if (i > -1) { + group.numbers.slice(i, 1); + textsecure.storage.putEncrypted("group" + groupId, group); + removeGroupFromNumber(groupId, number); + } + + return group.numbers; + }, + + addNumbers: function(groupId, numbers) { + var group = textsecure.storage.getEncrypted("group" + groupId); + if (group === undefined) + return undefined; + + for (var i in numbers) { + var number = numbers[i]; + if (!textsecure.utils.isNumberSane(number)) + throw new Error("Invalid number in set to add to group"); + if (group.numbers.indexOf(number) < 0) { + group.numbers.push(number); + addGroupToNumber(groupId, number); + } + } + + textsecure.storage.putEncrypted("group" + groupId, group); + return group.numbers; + }, + + deleteGroup: function(groupId) { + textsecure.storage.removeEncrypted("group" + groupId); + }, + + getGroup: function(groupId) { + var group = textsecure.storage.getEncrypted("group" + groupId); + if (group === undefined) + return undefined; + + return { id: groupId, numbers: group.numbers }; //TODO: avatar/name tracking + } + }; + + var addGroupToNumber = function(groupId, number) { + var membership = textsecure.storage.getEncrypted("groupMembership" + number, [groupId]); + if (membership.indexOf(groupId) < 0) + membership.push(groupId); + textsecure.storage.putEncrypted("groupMembership" + number, membership); + } + + var removeGroupFromNumber = function(groupId, number) { + var membership = textsecure.storage.getEncrypted("groupMembership" + number, [groupId]); + membership = membership.filter(function(group) { return group != groupId; }); + if (membership.length == 0) + textsecure.storage.removeEncrypted("groupMembership" + number); + else + textsecure.storage.putEncrypted("groupMembership" + number, membership); + } + +})(); + +/* vim: ts=4:sw=4:expandtab + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +window.textsecure = window.textsecure || {}; + +window.textsecure.api = function () { + 'use strict'; + + var self = {}; + + /************************************************ + *** Utilities to communicate with the server *** + ************************************************/ + // Staging server + var URL_BASE = "https://textsecure-service-staging.whispersystems.org"; + self.relay = "textsecure-service-staging.whispersystems.org"; + var ATTACHMENT_HOST = "whispersystems-textsecure-attachments-staging.s3.amazonaws.com"; + + // This is the real server + //var URL_BASE = "https://textsecure-service.whispersystems.org"; + + var URL_CALLS = {}; + URL_CALLS.accounts = "/v1/accounts"; + URL_CALLS.devices = "/v1/devices"; + URL_CALLS.keys = "/v2/keys"; + URL_CALLS.push = "/v1/websocket"; + URL_CALLS.temp_push = "/v1/temp_websocket"; + URL_CALLS.messages = "/v1/messages"; + URL_CALLS.attachment = "/v1/attachments"; + + /** + * REQUIRED PARAMS: + * call: URL_CALLS entry + * httpType: POST/GET/PUT/etc + * OPTIONAL PARAMS: + * success_callback: function(response object) called on success + * error_callback: function(http status code = -1 or != 200) called on failure + * urlParameters: crap appended to the url (probably including a leading /) + * user: user name to be sent in a basic auth header + * password: password to be sent in a basic auth headerA + * do_auth: alternative to user/password where user/password are figured out automagically + * jsonData: JSON data sent in the request body + */ + var doAjax = function (param) { + if (param.urlParameters === undefined) { + param.urlParameters = ""; + } + + if (param.do_auth) { + param.user = textsecure.storage.getUnencrypted("number_id"); + param.password = textsecure.storage.getEncrypted("password"); + } + + return new Promise(function (resolve, reject) { + $.ajax(URL_BASE + URL_CALLS[param.call] + param.urlParameters, { + type : param.httpType, + data : param.jsonData && textsecure.utils.jsonThing(param.jsonData), + contentType : 'application/json; charset=utf-8', + dataType : 'json', + + beforeSend : function (xhr) { + if (param.user !== undefined && + param.password !== undefined) + xhr.setRequestHeader("Authorization", "Basic " + btoa(getString(param.user) + ":" + getString(param.password))); + }, + + success : function(response, textStatus, jqXHR) { + resolve(response); + }, + + error : function(jqXHR, textStatus, errorThrown) { + var code = jqXHR.status; + if (code === 200) { + // happens sometimes when we get no response + // (TODO: Fix server to return 204? instead) + resolve(null); + return; + } + if (code > 999 || code < 100) + code = -1; + try { + switch (code) { + case -1: + textsecure.throwHumanError(code, "HTTPError", + "Failed to connect to the server, please check your network connection."); + case 413: + textsecure.throwHumanError(code, "HTTPError", + "Rate limit exceeded, please try again later."); + case 403: + textsecure.throwHumanError(code, "HTTPError", + "Invalid code, please try again."); + case 417: + // TODO: This shouldn't be a thing?, but its in the API doc? + textsecure.throwHumanError(code, "HTTPError", + "Number already registered."); + case 401: + textsecure.throwHumanError(code, "HTTPError", + "Invalid authentication, most likely someone re-registered and invalidated our registration."); + case 404: + textsecure.throwHumanError(code, "HTTPError", + "Number is not registered with TextSecure."); + default: + textsecure.throwHumanError(code, "HTTPError", + "The server rejected our query, please file a bug report."); + } + } catch (e) { + if (jqXHR.responseJSON) + e.response = jqXHR.responseJSON; + reject(e); + } + } + }); + }); + }; + + function requestVerificationCode(number, transport) { + return doAjax({ + call : 'accounts', + httpType : 'GET', + urlParameters : '/' + transport + '/code/' + number, + }); + }; + self.requestVerificationSMS = function(number) { + return requestVerificationCode(number, 'sms'); + }; + self.requestVerificationVoice = function(number) { + return requestVerificationCode(number, 'voice'); + }; + + self.confirmCode = function(number, code, password, + signaling_key, registrationId, single_device) { + var call = single_device ? 'accounts' : 'devices'; + var urlPrefix = single_device ? '/code/' : '/'; + + return doAjax({ + call : call, + httpType : 'PUT', + urlParameters : urlPrefix + code, + user : number, + password : password, + jsonData : { signalingKey : btoa(getString(signaling_key)), + supportsSms : false, + fetchesMessages : true, + registrationId : registrationId} + }); + }; + + self.registerKeys = function(genKeys) { + var keys = {}; + keys.identityKey = btoa(getString(genKeys.identityKey)); + keys.signedPreKey = {keyId: genKeys.signedPreKey.keyId, publicKey: btoa(getString(genKeys.signedPreKey.publicKey)), + signature: btoa(getString(genKeys.signedPreKey.signature))}; + + keys.preKeys = []; + var j = 0; + for (var i in genKeys.preKeys) + keys.preKeys[j++] = {keyId: i, publicKey: btoa(getString(genKeys.preKeys[i].publicKey))}; + + //TODO: This is just to make the server happy (v2 clients should choke on publicKey), + // it needs removed before release + keys.lastResortKey = {keyId: 0x7fffFFFF, publicKey: btoa("42")}; + + return doAjax({ + call : 'keys', + httpType : 'PUT', + do_auth : true, + jsonData : keys, + }); + }; + + self.getKeysForNumber = function(number, deviceId) { + if (deviceId === undefined) + deviceId = "*"; + + return doAjax({ + call : 'keys', + httpType : 'GET', + do_auth : true, + urlParameters : "/" + number + "/" + deviceId, + }).then(function(res) { + var promises = []; + res.identityKey = StringView.base64ToBytes(res.identityKey); + for (var i = 0; i < res.devices.length; i++) { + res.devices[i].signedPreKey.publicKey = StringView.base64ToBytes(res.devices[i].signedPreKey.publicKey); + res.devices[i].signedPreKey.signature = StringView.base64ToBytes(res.devices[i].signedPreKey.signature); + promises[i] = window.textsecure.crypto.Ed25519Verify(res.identityKey, res.devices[i].signedPreKey.publicKey, res.devices[i].signedPreKey.signature); + res.devices[i].preKey.publicKey = StringView.base64ToBytes(res.devices[i].preKey.publicKey); + //TODO: Is this still needed? + //if (res.devices[i].keyId === undefined) + // res.devices[i].keyId = 0; + } + return Promise.all(promises).then(function() { + return res; + }); + }); + }; + + self.sendMessages = function(destination, messageArray) { + //TODO: Do this conversion somewhere else? + for (var i = 0; i < messageArray.length; i++) + messageArray[i].body = btoa(messageArray[i].body); + var jsonData = { messages: messageArray }; + if (messageArray[0].relay !== undefined) + jsonData.relay = messageArray[0].relay; + jsonData.timestamp = messageArray[0].timestamp; + + return doAjax({ + call : 'messages', + httpType : 'PUT', + urlParameters : '/' + destination, + do_auth : true, + jsonData : jsonData, + }); + }; + + self.getAttachment = function(id) { + return doAjax({ + call : 'attachment', + httpType : 'GET', + urlParameters : '/' + id, + do_auth : true, + }).then(function(response) { + return new Promise(function(resolve, reject) { + $.ajax(response.location, { + type : "GET", + xhrFields: { + responseType: "arraybuffer" + }, + headers: { + "Content-Type": "application/octet-stream" + }, + + success : function(response, textStatus, jqXHR) { + resolve(response); + }, + + 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); + } + }); + }); + }); + }; + + var id_regex = RegExp( "^https:\/\/" + ATTACHMENT_HOST + "\/(\\d+)\?"); + 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, + processData : false, + success : function() { + try { + // Parse the id as a string from the location url + // (workaround for ids too large for Javascript numbers) + var id = response.location.match(id_regex)[1]; + resolve(id); + } catch(e) { + reject(e); + } + }, + 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); + } + }); + }); + }); + }; + + var getWebsocket = function(url, auth, reconnectTimeout) { + var URL = URL_BASE.replace(/^http/g, 'ws') + url + '/?'; + var params = ''; + if (auth) { + var user = textsecure.storage.getUnencrypted("number_id"); + var password = textsecure.storage.getEncrypted("password"); + var params = $.param({ + login: '+' + user.substring(1), + password: password + }); + } + return window.textsecure.websocket(URL+params) + } + + self.getMessageWebsocket = function() { + return getWebsocket(URL_CALLS['push'], true, 1000); + } + + self.getTempWebsocket = function() { + //XXX + var socketWrapper = { onmessage: function() {}, ondisconnect: function() {}, onconnect: function() {} }; + setTimeout(function() { + socketWrapper.onmessage({uuid: "404-42-magic"}); + }, 1000); + return socketWrapper; + //return getWebsocket(URL_CALLS['temp_push'], false, 5000); + } + + return self; +}(); + +/* vim: ts=4:sw=4 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ +;(function() { + window.textsecure = window.textsecure || {}; + + /* + * textsecure.crypto + * glues together various implementations into a single interface + * for all low-level crypto operations, + */ + + function curve25519() { + // use native client opportunistically, since it's faster + return textsecure.nativeclient || window.curve25519; + } + + window.textsecure.crypto = { + getRandomBytes: function(size) { + // At some point we might consider XORing in hashes of random + // UI events to strengthen ourselves against RNG flaws in crypto.getRandomValues + // ie maybe take a look at how Gibson does it at https://www.grc.com/r&d/js.htm + var array = new Uint8Array(size); + window.crypto.getRandomValues(array); + return array.buffer; + }, + encrypt: function(key, data, iv) { + return window.crypto.subtle.importKey('raw', key, {name: 'AES-CBC'}, false, ['encrypt']).then(function(key) { + return window.crypto.subtle.encrypt({name: 'AES-CBC', iv: new Uint8Array(iv)}, key, data); + }); + }, + decrypt: function(key, data, iv) { + return window.crypto.subtle.importKey('raw', key, {name: 'AES-CBC'}, false, ['decrypt']).then(function(key) { + return window.crypto.subtle.decrypt({name: 'AES-CBC', iv: new Uint8Array(iv)}, key, data); + }); + }, + sign: function(key, data) { + return window.crypto.subtle.importKey('raw', key, {name: 'HMAC', hash: {name: 'SHA-256'}}, false, ['sign']).then(function(key) { + return window.crypto.subtle.sign( {name: 'HMAC', hash: 'SHA-256'}, key, data); + }); + }, + + HKDF: function(input, salt, info) { + // Specific implementation of RFC 5869 that only returns the first 3 32-byte chunks + // TODO: We dont always need the third chunk, we might skip it + return window.textsecure.crypto.sign(salt, input).then(function(PRK) { + var infoBuffer = new ArrayBuffer(info.byteLength + 1 + 32); + var infoArray = new Uint8Array(infoBuffer); + infoArray.set(new Uint8Array(info), 32); + infoArray[infoArray.length - 1] = 1; + return window.textsecure.crypto.sign(PRK, infoBuffer.slice(32)).then(function(T1) { + infoArray.set(new Uint8Array(T1)); + infoArray[infoArray.length - 1] = 2; + return window.textsecure.crypto.sign(PRK, infoBuffer).then(function(T2) { + infoArray.set(new Uint8Array(T2)); + infoArray[infoArray.length - 1] = 3; + return window.textsecure.crypto.sign(PRK, infoBuffer).then(function(T3) { + return [ T1, T2, T3 ]; + }); + }); + }); + }); + }, + + // Curve 25519 crypto + createKeyPair: function(privKey) { + if (privKey === undefined) { + privKey = textsecure.crypto.getRandomBytes(32); + } + if (privKey.byteLength != 32) { + throw new Error("Invalid private key"); + } + + return curve25519().keyPair(privKey).then(function(raw_keys) { + // prepend version byte + var origPub = new Uint8Array(raw_keys.pubKey); + var pub = new Uint8Array(33); + pub.set(origPub, 1); + pub[0] = 5; + + return { pubKey: pub.buffer, privKey: raw_keys.privKey }; + }); + }, + ECDHE: function(pubKey, privKey) { + pubKey = validatePubKeyFormat(pubKey); + if (privKey === undefined || privKey.byteLength != 32) + throw new Error("Invalid private key"); + + if (pubKey === undefined || pubKey.byteLength != 32) + throw new Error("Invalid public key"); + + return curve25519().sharedSecret(pubKey, privKey); + }, + Ed25519Sign: function(privKey, message) { + if (privKey === undefined || privKey.byteLength != 32) + throw new Error("Invalid private key"); + + if (message === undefined) + throw new Error("Invalid message"); + + return curve25519().sign(privKey, message); + }, + Ed25519Verify: function(pubKey, msg, sig) { + pubKey = validatePubKeyFormat(pubKey); + + if (pubKey === undefined || pubKey.byteLength != 32) + throw new Error("Invalid public key"); + + if (msg === undefined) + throw new Error("Invalid message"); + + if (sig === undefined || sig.byteLength != 64) + throw new Error("Invalid signature"); + + return curve25519().verify(pubKey, msg, sig); + } + }; + + var validatePubKeyFormat = function(pubKey) { + if (pubKey === undefined || ((pubKey.byteLength != 33 || new Uint8Array(pubKey)[0] != 5) && pubKey.byteLength != 32)) + throw new Error("Invalid public key"); + if (pubKey.byteLength == 33) { + return pubKey.slice(1); + } else { + console.error("WARNING: Expected pubkey of length 33, please report the ST and client that generated the pubkey"); + return pubKey; + } + }; + +})(); + +/* vim: ts=4:sw=4 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ +;(function() { + +'use strict'; +window.textsecure = window.textsecure || {}; + +window.textsecure.protocol = function() { + var self = {}; + + /****************************** + *** Random constants/utils *** + ******************************/ + // We consider messages lost after a week and might throw away keys at that point + // (also the time between signedPreKey regenerations) + var MESSAGE_LOST_THRESHOLD_MS = 1000*60*60*24*7; + + function objectContainsKeys(object) { + var count = 0; + for (var key in object) { + count++; + break; + } + return count != 0; + } + + /*************************** + *** Key/session storage *** + ***************************/ + var crypto_storage = {}; + + crypto_storage.putKeyPair = function(keyName, keyPair) { + textsecure.storage.putEncrypted("25519Key" + keyName, keyPair); + } + + crypto_storage.getNewStoredKeyPair = function(keyName) { + return textsecure.crypto.createKeyPair().then(function(keyPair) { + crypto_storage.putKeyPair(keyName, keyPair); + return keyPair; + }); + } + + crypto_storage.getStoredKeyPair = function(keyName) { + var res = textsecure.storage.getEncrypted("25519Key" + keyName); + if (res === undefined) + return undefined; + return { pubKey: toArrayBuffer(res.pubKey), privKey: toArrayBuffer(res.privKey) }; + } + + crypto_storage.removeStoredKeyPair = function(keyName) { + textsecure.storage.removeEncrypted("25519Key" + keyName); + } + + crypto_storage.getIdentityKey = function() { + return this.getStoredKeyPair("identityKey"); + } + + crypto_storage.saveSession = function(encodedNumber, session, registrationId) { + var device = textsecure.storage.devices.getDeviceObject(encodedNumber); + if (device === undefined) + device = { sessions: {}, encodedNumber: encodedNumber }; + + if (registrationId !== undefined) + device.registrationId = registrationId; + + crypto_storage.saveSessionAndDevice(device, session); + } + + crypto_storage.saveSessionAndDevice = function(device, session) { + if (device.sessions === undefined) + device.sessions = {}; + var sessions = device.sessions; + + var doDeleteSession = false; + if (session.indexInfo.closed == -1 || device.identityKey === undefined) + device.identityKey = session.indexInfo.remoteIdentityKey; + + if (session.indexInfo.closed != -1) { + doDeleteSession = (session.indexInfo.closed < (new Date().getTime() - MESSAGE_LOST_THRESHOLD_MS)); + + if (!doDeleteSession) { + var keysLeft = false; + for (var key in session) { + if (key != "indexInfo" && key != "oldRatchetList" && key != "currentRatchet") { + keysLeft = true; + break; + } + } + doDeleteSession = !keysLeft; + console.log((doDeleteSession ? "Deleting " : "Not deleting ") + "closed session which has not yet timed out"); + } else + console.log("Deleting closed session due to timeout (created at " + session.indexInfo.closed + ")"); + } + + if (doDeleteSession) + delete sessions[getString(session.indexInfo.baseKey)]; + else + sessions[getString(session.indexInfo.baseKey)] = session; + + var openSessionRemaining = false; + for (var key in sessions) + if (sessions[key].indexInfo.closed == -1) + openSessionRemaining = true; + if (!openSessionRemaining) + try { + delete device['registrationId']; + } catch(_) {} + + textsecure.storage.devices.saveDeviceObject(device); + } + + var getSessions = function(encodedNumber) { + var device = textsecure.storage.devices.getDeviceObject(encodedNumber); + if (device === undefined || device.sessions === undefined) + return undefined; + return device.sessions; + } + + crypto_storage.getOpenSession = function(encodedNumber) { + var sessions = getSessions(encodedNumber); + if (sessions === undefined) + return undefined; + + for (var key in sessions) + if (sessions[key].indexInfo.closed == -1) + return sessions[key]; + return undefined; + } + + crypto_storage.getSessionByRemoteEphemeralKey = function(encodedNumber, remoteEphemeralKey) { + var sessions = getSessions(encodedNumber); + if (sessions === undefined) + return undefined; + + var searchKey = getString(remoteEphemeralKey); + + var openSession = undefined; + for (var key in sessions) { + if (sessions[key].indexInfo.closed == -1) { + if (openSession !== undefined) + throw new Error("Datastore inconsistensy: multiple open sessions for " + encodedNumber); + openSession = sessions[key]; + } + if (sessions[key][searchKey] !== undefined) + return sessions[key]; + } + if (openSession !== undefined) + return openSession; + + return undefined; + } + + crypto_storage.getSessionOrIdentityKeyByBaseKey = function(encodedNumber, baseKey) { + var sessions = getSessions(encodedNumber); + var device = textsecure.storage.devices.getDeviceObject(encodedNumber); + if (device === undefined) + return undefined; + + var preferredSession = device.sessions && device.sessions[getString(baseKey)]; + if (preferredSession !== undefined) + return preferredSession; + + if (device.identityKey !== undefined) + return { indexInfo: { remoteIdentityKey: device.identityKey } }; + + throw new Error("Datastore inconsistency: device was stored without identity key"); + } + + /***************************** + *** Internal Crypto stuff *** + *****************************/ + var HKDF = function(input, salt, info) { + // HKDF for TextSecure has a bit of additional handling - salts always end up being 32 bytes + if (salt == '') + salt = new ArrayBuffer(32); + if (salt.byteLength != 32) + throw new Error("Got salt of incorrect length"); + + info = toArrayBuffer(info); // TODO: maybe convert calls? + + return textsecure.crypto.HKDF(input, salt, info); + } + + var verifyMAC = function(data, key, mac) { + return textsecure.crypto.sign(key, data).then(function(calculated_mac) { + if (!isEqual(calculated_mac, mac, true)) + throw new Error("Bad MAC"); + }); + } + + /****************************** + *** Ratchet implementation *** + ******************************/ + var calculateRatchet = function(session, remoteKey, sending) { + var ratchet = session.currentRatchet; + + return textsecure.crypto.ECDHE(remoteKey, toArrayBuffer(ratchet.ephemeralKeyPair.privKey)).then(function(sharedSecret) { + return HKDF(sharedSecret, toArrayBuffer(ratchet.rootKey), "WhisperRatchet").then(function(masterKey) { + if (sending) + session[getString(ratchet.ephemeralKeyPair.pubKey)] = { messageKeys: {}, chainKey: { counter: -1, key: masterKey[1] } }; + else + session[getString(remoteKey)] = { messageKeys: {}, chainKey: { counter: -1, key: masterKey[1] } }; + ratchet.rootKey = masterKey[0]; + }); + }); + } + + var initSession = function(isInitiator, ourEphemeralKey, ourSignedKey, encodedNumber, theirIdentityPubKey, theirEphemeralPubKey, theirSignedPubKey) { + var ourIdentityKey = crypto_storage.getIdentityKey(); + + if (isInitiator) { + if (ourSignedKey !== undefined) + throw new Error("Invalid call to initSession"); + ourSignedKey = ourEphemeralKey; + } else { + if (theirSignedPubKey !== undefined) + throw new Error("Invalid call to initSession"); + theirSignedPubKey = theirEphemeralPubKey; + } + + var sharedSecret; + if (ourEphemeralKey === undefined || theirEphemeralPubKey === undefined) + sharedSecret = new Uint8Array(32 * 4); + else + sharedSecret = new Uint8Array(32 * 5); + + for (var i = 0; i < 32; i++) + sharedSecret[i] = 0xff; + + return textsecure.crypto.ECDHE(theirSignedPubKey, ourIdentityKey.privKey).then(function(ecRes1) { + function finishInit() { + return textsecure.crypto.ECDHE(theirSignedPubKey, ourSignedKey.privKey).then(function(ecRes) { + sharedSecret.set(new Uint8Array(ecRes), 32 * 3); + + return HKDF(sharedSecret.buffer, '', "WhisperText").then(function(masterKey) { + var session = {currentRatchet: { rootKey: masterKey[0], lastRemoteEphemeralKey: theirSignedPubKey, previousCounter: 0 }, + indexInfo: { remoteIdentityKey: theirIdentityPubKey, closed: -1 }, + oldRatchetList: [] + }; + if (!isInitiator) + session.indexInfo.baseKey = theirEphemeralPubKey; + else + session.indexInfo.baseKey = ourEphemeralKey.pubKey; + + // If we're initiating we go ahead and set our first sending ephemeral key now, + // otherwise we figure it out when we first maybeStepRatchet with the remote's ephemeral key + if (isInitiator) { + return textsecure.crypto.createKeyPair().then(function(ourSendingEphemeralKey) { + session.currentRatchet.ephemeralKeyPair = ourSendingEphemeralKey; + return calculateRatchet(session, theirSignedPubKey, true).then(function() { + return session; + }); + }); + } else { + session.currentRatchet.ephemeralKeyPair = ourSignedKey; + return session; + } + }); + }); + } + + var promise; + if (ourEphemeralKey === undefined || theirEphemeralPubKey === undefined) + promise = Promise.resolve(new ArrayBuffer(0)); + else + promise = textsecure.crypto.ECDHE(theirEphemeralPubKey, ourEphemeralKey.privKey); + return promise.then(function(ecRes4) { + sharedSecret.set(new Uint8Array(ecRes4), 32 * 4); + + if (isInitiator) + return textsecure.crypto.ECDHE(theirIdentityPubKey, ourSignedKey.privKey).then(function(ecRes2) { + sharedSecret.set(new Uint8Array(ecRes1), 32); + sharedSecret.set(new Uint8Array(ecRes2), 32 * 2); + }).then(finishInit); + else + return textsecure.crypto.ECDHE(theirIdentityPubKey, ourSignedKey.privKey).then(function(ecRes2) { + sharedSecret.set(new Uint8Array(ecRes1), 32 * 2); + sharedSecret.set(new Uint8Array(ecRes2), 32) + }).then(finishInit); + }); + }); + } + + var removeOldChains = function(session) { + // Sending ratchets are always removed when we step because we never need them again + // Receiving ratchets are either removed if we step with all keys used up to previousCounter + // and are otherwise added to the oldRatchetList, which we parse here and remove ratchets + // older than a week (we assume the message was lost and move on with our lives at that point) + var newList = []; + for (var i = 0; i < session.oldRatchetList.length; i++) { + var entry = session.oldRatchetList[i]; + var ratchet = getString(entry.ephemeralKey); + console.log("Checking old chain with added time " + (entry.added/1000)); + if ((!objectContainsKeys(session[ratchet].messageKeys) && (session[ratchet].chainKey === undefined || session[ratchet].chainKey.key === undefined)) + || entry.added < new Date().getTime() - MESSAGE_LOST_THRESHOLD_MS) { + delete session[ratchet]; + console.log("...deleted"); + } else + newList[newList.length] = entry; + } + session.oldRatchetList = newList; + } + + var closeSession = function(session, sessionClosedByRemote) { + if (session.indexInfo.closed > -1) + return; + + // After this has run, we can still receive messages on ratchet chains which + // were already open (unless we know we dont need them), + // but we cannot send messages or step the ratchet + + // Delete current sending ratchet + delete session[getString(session.currentRatchet.ephemeralKeyPair.pubKey)]; + // Move all receive ratchets to the oldRatchetList to mark them for deletion + for (var i in session) { + if (session[i].chainKey !== undefined && session[i].chainKey.key !== undefined) { + if (!sessionClosedByRemote) + session.oldRatchetList[session.oldRatchetList.length] = { added: new Date().getTime(), ephemeralKey: i }; + else + delete session[i].chainKey.key; + } + } + // Delete current root key and our ephemeral key pair to disallow ratchet stepping + delete session.currentRatchet['rootKey']; + delete session.currentRatchet['ephemeralKeyPair']; + session.indexInfo.closed = new Date().getTime(); + removeOldChains(session); + } + + self.closeOpenSessionForDevice = function(encodedNumber) { + var session = crypto_storage.getOpenSession(encodedNumber); + if (session === undefined) + return; + + closeSession(session); + crypto_storage.saveSession(encodedNumber, session); + } + + var initSessionFromPreKeyWhisperMessage; + var decryptWhisperMessage; + var handlePreKeyWhisperMessage = function(from, encodedMessage) { + var preKeyProto = textsecure.protobuf.PreKeyWhisperMessage.decode(encodedMessage, 'binary'); + return initSessionFromPreKeyWhisperMessage(from, preKeyProto).then(function(sessions) { + return decryptWhisperMessage(from, getString(preKeyProto.message), sessions[0], preKeyProto.registrationId).then(function(result) { + if (sessions[1] !== undefined) + sessions[1](); + return result; + }); + }); + } + + 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); + + initSessionFromPreKeyWhisperMessage = function(encodedNumber, message) { + var preKeyPair = crypto_storage.getStoredKeyPair("preKey" + message.preKeyId); + var signedPreKeyPair = crypto_storage.getStoredKeyPair("signedKey" + message.signedPreKeyId); + + var session = crypto_storage.getSessionOrIdentityKeyByBaseKey(encodedNumber, toArrayBuffer(message.baseKey)); + var open_session = crypto_storage.getOpenSession(encodedNumber); + if (signedPreKeyPair === undefined) { + // Session may or may not be the right one, but if its not, we can't do anything about it + // ...fall through and let decryptWhisperMessage handle that case + if (session !== undefined && session.currentRatchet !== undefined) + return Promise.resolve([session, undefined]); + else + throw new Error("Missing Signed PreKey for PreKeyWhisperMessage"); + } + if (session !== undefined) { + // Duplicate PreKeyMessage for session: + if (isEqual(session.indexInfo.baseKey, message.baseKey, false)) + return Promise.resolve([session, undefined]); + + // We already had a session/known identity key: + if (isEqual(session.indexInfo.remoteIdentityKey, message.identityKey, false)) { + // If the identity key matches the previous one, close the previous one and use the new one + if (open_session !== undefined) + closeSession(open_session); // To be returned and saved later + } else { + // ...otherwise create an error that the UI will pick up and ask the user if they want to re-negotiate + throw new textsecure.IncomingIdentityKeyError(encodedNumber, getString(message.encode())); + } + } + return initSession(false, preKeyPair, signedPreKeyPair, encodedNumber, toArrayBuffer(message.identityKey), toArrayBuffer(message.baseKey), undefined) + .then(function(new_session) { + // Note that the session is not actually saved until the very end of decryptWhisperMessage + // ... to ensure that the sender actually holds the private keys for all reported pubkeys + return [new_session, function() { + if (open_session !== undefined) + crypto_storage.saveSession(encodedNumber, open_session); + crypto_storage.removeStoredKeyPair("preKey" + message.preKeyId); + }]; + });; + } + + var fillMessageKeys = function(chain, counter) { + if (chain.chainKey.counter + 1000 < counter) //TODO: maybe 1000 is too low/high in some cases? + return Promise.resolve(); // Stalker, much? + + if (chain.chainKey.counter >= counter) + return Promise.resolve(); // Already calculated + + if (chain.chainKey.key === undefined) + throw new Error("Got invalid request to extend chain after it was already closed"); + + var key = toArrayBuffer(chain.chainKey.key); + var byteArray = new Uint8Array(1); + byteArray[0] = 1; + return textsecure.crypto.sign(key, byteArray.buffer).then(function(mac) { + byteArray[0] = 2; + return textsecure.crypto.sign(key, byteArray.buffer).then(function(key) { + chain.messageKeys[chain.chainKey.counter + 1] = mac; + chain.chainKey.key = key + chain.chainKey.counter += 1; + return fillMessageKeys(chain, counter); + }); + }); + } + + var maybeStepRatchet = function(session, remoteKey, previousCounter) { + if (session[getString(remoteKey)] !== undefined) + return Promise.resolve(); + + var ratchet = session.currentRatchet; + + var finish = function() { + return calculateRatchet(session, remoteKey, false).then(function() { + // Now swap the ephemeral key and calculate the new sending chain + var previousRatchet = getString(ratchet.ephemeralKeyPair.pubKey); + if (session[previousRatchet] !== undefined) { + ratchet.previousCounter = session[previousRatchet].chainKey.counter; + delete session[previousRatchet]; + } + + return textsecure.crypto.createKeyPair().then(function(keyPair) { + ratchet.ephemeralKeyPair = keyPair; + return calculateRatchet(session, remoteKey, true).then(function() { + ratchet.lastRemoteEphemeralKey = remoteKey; + }); + }); + }); + } + + var previousRatchet = session[getString(ratchet.lastRemoteEphemeralKey)]; + if (previousRatchet !== undefined) { + return fillMessageKeys(previousRatchet, previousCounter).then(function() { + delete previousRatchet.chainKey.key; + if (!objectContainsKeys(previousRatchet.messageKeys)) + delete session[getString(ratchet.lastRemoteEphemeralKey)]; + else + session.oldRatchetList[session.oldRatchetList.length] = { added: new Date().getTime(), ephemeralKey: ratchet.lastRemoteEphemeralKey }; + }).then(finish); + } else + return finish(); + } + + // returns decrypted protobuf + decryptWhisperMessage = function(encodedNumber, messageBytes, session, registrationId) { + if (messageBytes[0] != String.fromCharCode((3 << 4) | 3)) + throw new Error("Bad version number on WhisperMessage"); + + var messageProto = messageBytes.substring(1, messageBytes.length - 8); + var mac = messageBytes.substring(messageBytes.length - 8, messageBytes.length); + + var message = textsecure.protobuf.WhisperMessage.decode(messageProto, 'binary'); + var remoteEphemeralKey = toArrayBuffer(message.ephemeralKey); + + if (session === undefined) { + var session = crypto_storage.getSessionByRemoteEphemeralKey(encodedNumber, remoteEphemeralKey); + if (session === undefined) + throw new Error("No session found to decrypt message from " + encodedNumber); + } + + return maybeStepRatchet(session, remoteEphemeralKey, message.previousCounter).then(function() { + var chain = session[getString(message.ephemeralKey)]; + + return fillMessageKeys(chain, message.counter).then(function() { + return HKDF(toArrayBuffer(chain.messageKeys[message.counter]), '', "WhisperMessageKeys").then(function(keys) { + delete chain.messageKeys[message.counter]; + + var messageProtoArray = toArrayBuffer(messageProto); + var macInput = new Uint8Array(messageProtoArray.byteLength + 33*2 + 1); + macInput.set(new Uint8Array(toArrayBuffer(session.indexInfo.remoteIdentityKey))); + macInput.set(new Uint8Array(toArrayBuffer(crypto_storage.getIdentityKey().pubKey)), 33); + macInput[33*2] = (3 << 4) | 3; + macInput.set(new Uint8Array(messageProtoArray), 33*2 + 1); + + return verifyMAC(macInput.buffer, keys[1], mac).then(function() { + return window.textsecure.crypto.decrypt(keys[0], toArrayBuffer(message.ciphertext), keys[2].slice(0, 16)) + .then(function(paddedPlaintext) { + + paddedPlaintext = new Uint8Array(paddedPlaintext); + var plaintext; + for (var i = paddedPlaintext.length - 1; i >= 0; i--) { + if (paddedPlaintext[i] == 0x80) { + plaintext = new Uint8Array(i); + plaintext.set(paddedPlaintext.subarray(0, i)); + plaintext = plaintext.buffer; + break; + } else if (paddedPlaintext[i] != 0x00) + throw new Error('Invalid padding'); + } + + delete session['pendingPreKey']; + + var finalMessage = textsecure.protobuf.PushMessageContent.decode(plaintext); + + if ((finalMessage.flags & textsecure.protobuf.PushMessageContent.Flags.END_SESSION) + == textsecure.protobuf.PushMessageContent.Flags.END_SESSION) + closeSession(session, true); + + removeOldChains(session); + + crypto_storage.saveSession(encodedNumber, session, registrationId); + return finalMessage; + }); + }); + }); + }); + }); + } + + /************************* + *** Public crypto API *** + *************************/ + // Decrypts message into a raw string + self.decryptWebsocketMessage = function(message) { + var signaling_key = textsecure.storage.getEncrypted("signaling_key"); //TODO: in crypto_storage + var aes_key = toArrayBuffer(signaling_key.substring(0, 32)); + var mac_key = toArrayBuffer(signaling_key.substring(32, 32 + 20)); + + var decodedMessage = message.toArrayBuffer(); + if (new Uint8Array(decodedMessage)[0] != 1) + throw new Error("Got bad version number: " + decodedMessage[0]); + + var iv = decodedMessage.slice(1, 1 + 16); + var ciphertext = decodedMessage.slice(1 + 16, decodedMessage.byteLength - 10); + var ivAndCiphertext = decodedMessage.slice(0, decodedMessage.byteLength - 10); + var mac = decodedMessage.slice(decodedMessage.byteLength - 10, decodedMessage.byteLength); + + return verifyMAC(ivAndCiphertext, mac_key, mac).then(function() { + return window.textsecure.crypto.decrypt(aes_key, ciphertext, iv); + }); + }; + + self.decryptAttachment = function(encryptedBin, keys) { + var aes_key = keys.slice(0, 32); + var mac_key = keys.slice(32, 64); + + var iv = encryptedBin.slice(0, 16); + var ciphertext = encryptedBin.slice(16, encryptedBin.byteLength - 32); + var ivAndCiphertext = encryptedBin.slice(0, encryptedBin.byteLength - 32); + var mac = encryptedBin.slice(encryptedBin.byteLength - 32, encryptedBin.byteLength); + + return verifyMAC(ivAndCiphertext, mac_key, mac).then(function() { + return window.textsecure.crypto.decrypt(aes_key, ciphertext, iv); + }); + }; + + self.encryptAttachment = function(plaintext, keys, iv) { + var aes_key = keys.slice(0, 32); + var mac_key = keys.slice(32, 64); + + return window.textsecure.crypto.encrypt(aes_key, plaintext, iv).then(function(ciphertext) { + var ivAndCiphertext = new Uint8Array(16 + ciphertext.byteLength); + ivAndCiphertext.set(new Uint8Array(iv)); + ivAndCiphertext.set(new Uint8Array(ciphertext), 16); + + return textsecure.crypto.sign(mac_key, ivAndCiphertext.buffer).then(function(mac) { + var encryptedBin = new Uint8Array(16 + ciphertext.byteLength + 32); + encryptedBin.set(ivAndCiphertext); + encryptedBin.set(new Uint8Array(mac), 16 + ciphertext.byteLength); + return encryptedBin.buffer; + }); + }); + }; + + self.handleIncomingPushMessageProto = function(proto) { + switch(proto.type) { + case textsecure.protobuf.IncomingPushMessageSignal.Type.PLAINTEXT: + return Promise.resolve(textsecure.protobuf.PushMessageContent.decode(proto.message)); + case textsecure.protobuf.IncomingPushMessageSignal.Type.CIPHERTEXT: + var from = proto.source + "." + (proto.sourceDevice == null ? 0 : proto.sourceDevice); + return decryptWhisperMessage(from, getString(proto.message)); + case textsecure.protobuf.IncomingPushMessageSignal.Type.PREKEY_BUNDLE: + if (proto.message.readUint8() != ((3 << 4) | 3)) + throw new Error("Bad version byte"); + var from = proto.source + "." + (proto.sourceDevice == null ? 0 : proto.sourceDevice); + return handlePreKeyWhisperMessage(from, getString(proto.message)); + case textsecure.protobuf.IncomingPushMessageSignal.Type.RECEIPT: + return Promise.resolve(null); + default: + return new Promise(function(resolve, reject) { reject(new Error("Unknown message type")); }); + } + } + + // return Promise(encoded [PreKey]WhisperMessage) + self.encryptMessageFor = function(deviceObject, pushMessageContent) { + var session = crypto_storage.getOpenSession(deviceObject.encodedNumber); + + var doEncryptPushMessageContent = function() { + var msg = new textsecure.protobuf.WhisperMessage(); + var plaintext = toArrayBuffer(pushMessageContent.encode()); + + var paddedPlaintext = new Uint8Array(Math.ceil((plaintext.byteLength + 1) / 160.0) * 160 - 1); + paddedPlaintext.set(new Uint8Array(plaintext)); + paddedPlaintext[plaintext.byteLength] = 0x80; + + msg.ephemeralKey = toArrayBuffer(session.currentRatchet.ephemeralKeyPair.pubKey); + var chain = session[getString(msg.ephemeralKey)]; + + return fillMessageKeys(chain, chain.chainKey.counter + 1).then(function() { + return HKDF(toArrayBuffer(chain.messageKeys[chain.chainKey.counter]), '', "WhisperMessageKeys").then(function(keys) { + delete chain.messageKeys[chain.chainKey.counter]; + msg.counter = chain.chainKey.counter; + msg.previousCounter = session.currentRatchet.previousCounter; + + return window.textsecure.crypto.encrypt(keys[0], paddedPlaintext.buffer, keys[2].slice(0, 16)).then(function(ciphertext) { + msg.ciphertext = ciphertext; + var encodedMsg = toArrayBuffer(msg.encode()); + + var macInput = new Uint8Array(encodedMsg.byteLength + 33*2 + 1); + macInput.set(new Uint8Array(toArrayBuffer(crypto_storage.getIdentityKey().pubKey))); + macInput.set(new Uint8Array(toArrayBuffer(session.indexInfo.remoteIdentityKey)), 33); + macInput[33*2] = (3 << 4) | 3; + macInput.set(new Uint8Array(encodedMsg), 33*2 + 1); + + return textsecure.crypto.sign(keys[1], macInput.buffer).then(function(mac) { + var result = new Uint8Array(encodedMsg.byteLength + 9); + result[0] = (3 << 4) | 3; + result.set(new Uint8Array(encodedMsg), 1); + result.set(new Uint8Array(mac, 0, 8), encodedMsg.byteLength + 1); + + try { + delete deviceObject['signedKey']; + delete deviceObject['signedKeyId']; + delete deviceObject['preKey']; + delete deviceObject['preKeyId']; + } catch(_) {} + + removeOldChains(session); + + crypto_storage.saveSessionAndDevice(deviceObject, session); + return result; + }); + }); + }); + }); + } + + var preKeyMsg = new textsecure.protobuf.PreKeyWhisperMessage(); + preKeyMsg.identityKey = toArrayBuffer(crypto_storage.getIdentityKey().pubKey); + preKeyMsg.registrationId = textsecure.storage.getUnencrypted("registrationId"); + + if (session === undefined) { + return textsecure.crypto.createKeyPair().then(function(baseKey) { + preKeyMsg.preKeyId = deviceObject.preKeyId; + preKeyMsg.signedPreKeyId = deviceObject.signedKeyId; + preKeyMsg.baseKey = toArrayBuffer(baseKey.pubKey); + return initSession(true, baseKey, undefined, deviceObject.encodedNumber, + toArrayBuffer(deviceObject.identityKey), toArrayBuffer(deviceObject.preKey), toArrayBuffer(deviceObject.signedKey)) + .then(function(new_session) { + session = new_session; + session.pendingPreKey = { preKeyId: deviceObject.preKeyId, signedKeyId: deviceObject.signedKeyId, baseKey: baseKey.pubKey }; + return doEncryptPushMessageContent().then(function(message) { + preKeyMsg.message = message; + var result = String.fromCharCode((3 << 4) | 3) + getString(preKeyMsg.encode()); + return {type: 3, body: result}; + }); + }); + }); + } else + return doEncryptPushMessageContent().then(function(message) { + if (session.pendingPreKey !== undefined) { + preKeyMsg.baseKey = toArrayBuffer(session.pendingPreKey.baseKey); + preKeyMsg.preKeyId = session.pendingPreKey.preKeyId; + preKeyMsg.signedPreKeyId = session.pendingPreKey.signedKeyId; + preKeyMsg.message = message; + + var result = String.fromCharCode((3 << 4) | 3) + getString(preKeyMsg.encode()); + return {type: 3, body: result}; + } else + return {type: 1, body: getString(message)}; + }); + } + + var GENERATE_KEYS_KEYS_GENERATED = 100; + self.generateKeys = function() { + var identityKeyPair = crypto_storage.getIdentityKey(); + var identityKeyCalculated = function(identityKeyPair) { + var firstPreKeyId = textsecure.storage.getEncrypted("maxPreKeyId", 0); + textsecure.storage.putEncrypted("maxPreKeyId", firstPreKeyId + GENERATE_KEYS_KEYS_GENERATED); + + var signedKeyId = textsecure.storage.getEncrypted("signedKeyId", 0); + textsecure.storage.putEncrypted("signedKeyId", signedKeyId + 1); + + var keys = {}; + keys.identityKey = identityKeyPair.pubKey; + keys.preKeys = []; + + var generateKey = function(keyId) { + return crypto_storage.getNewStoredKeyPair("preKey" + keyId, false).then(function(keyPair) { + keys.preKeys[keyId] = {keyId: keyId, publicKey: keyPair.pubKey}; + }); + }; + + var promises = []; + for (var i = firstPreKeyId; i < firstPreKeyId + GENERATE_KEYS_KEYS_GENERATED; i++) + promises[i] = generateKey(i); + + promises[firstPreKeyId + GENERATE_KEYS_KEYS_GENERATED] = crypto_storage.getNewStoredKeyPair("signedKey" + signedKeyId).then(function(keyPair) { + return textsecure.crypto.Ed25519Sign(identityKeyPair.privKey, keyPair.pubKey).then(function(sig) { + keys.signedPreKey = {keyId: signedKeyId, publicKey: keyPair.pubKey, signature: sig}; + }); + }); + + //TODO: Process by date added and agressively call generateKeys when we get near maxPreKeyId in a message + crypto_storage.removeStoredKeyPair("signedKey" + (signedKeyId - 2)); + + return Promise.all(promises).then(function() { + return keys; + }); + } + if (identityKeyPair === undefined) + return crypto_storage.getNewStoredKeyPair("identityKey").then(function(keyPair) { return identityKeyCalculated(keyPair); }); + else + return identityKeyCalculated(identityKeyPair); + } + + //TODO: Dont always update prekeys here + if (textsecure.storage.getEncrypted("lastSignedKeyUpdate", Date.now()) < Date.now() - MESSAGE_LOST_THRESHOLD_MS) { + new Promise(function(resolve) { resolve(self.generateKeys()); }); + } + + + self.prepareTempWebsocket = function() { + var socketInfo = {}; + var keyPair; + + socketInfo.decryptAndHandleDeviceInit = function(deviceInit) { + var masterEphemeral = toArrayBuffer(deviceInit.masterEphemeralPubKey); + var message = toArrayBuffer(deviceInit.identityKeyMessage); + + return textsecure.crypto.ECDHE(masterEphemeral, keyPair.privKey).then(function(ecRes) { + return HKDF(ecRes, masterEphemeral, "WhisperDeviceInit").then(function(keys) { + if (new Uint8Array(message)[0] != (3 << 4) | 3) + throw new Error("Bad version number on IdentityKeyMessage"); + + var iv = message.slice(1, 16 + 1); + var mac = message.slice(message.length - 32, message.length); + var ivAndCiphertext = message.slice(0, message.length - 32); + var ciphertext = message.slice(16 + 1, message.length - 32); + + return verifyMAC(ivAndCiphertext, ecRes[1], mac).then(function() { + window.textsecure.crypto.decrypt(ecRes[0], ciphertext, iv).then(function(plaintext) { + var identityKeyMsg = textsecure.protobuf.IdentityKey.decode(plaintext); + + textsecure.crypto.createKeyPair(toArrayBuffer(identityKeyMsg.identityKey)).then(function(identityKeyPair) { + crypto_storage.putKeyPair("identityKey", identityKeyPair); + identityKeyMsg.identityKey = null; + + return identityKeyMsg; + }); + }); + }); + }); + }); + } + + return textsecure.crypto.createKeyPair().then(function(newKeyPair) { + keyPair = newKeyPair; + socketInfo.pubKey = keyPair.pubKey; + return socketInfo; + }); + } + + return self; +}(); + +})(); + +/* vim: ts=4:sw=4:expandtab + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ +// sendMessage(numbers = [], message = PushMessageContentProto, callback(success/failure map)) +window.textsecure.messaging = function() { + 'use strict'; + + var self = {}; + + function getKeysForNumber(number, updateDevices) { + var handleResult = function(response) { + for (var i in response.devices) { + if (updateDevices === undefined || updateDevices.indexOf(response.devices[i].deviceId) > -1) + textsecure.storage.devices.saveKeysToDeviceObject({ + encodedNumber: number + "." + response.devices[i].deviceId, + identityKey: response.identityKey, + preKey: response.devices[i].preKey.publicKey, + preKeyId: response.devices[i].preKey.keyId, + signedKey: response.devices[i].signedPreKey.publicKey, + signedKeyId: response.devices[i].signedPreKey.keyId, + registrationId: response.devices[i].registrationId + }); + } + }; + + var promises = []; + if (updateDevices !== undefined) + for (var i in updateDevices) + promises[promises.length] = textsecure.api.getKeysForNumber(number, updateDevices[i]).then(handleResult); + else + return textsecure.api.getKeysForNumber(number).then(handleResult); + + return Promise.all(promises); + } + + // success_callback(server success/failure map), error_callback(error_msg) + // message == PushMessageContentProto (NOT STRING) + function sendMessageToDevices(timestamp, 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.protocol.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: timestamp + }; + + 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 sendGroupProto; + var makeAttachmentPointer; + var refreshGroups = function(number) { + var groups = textsecure.storage.groups.getGroupListForNumber(number); + var promises = []; + for (var i in groups) { + var group = textsecure.storage.groups.getGroup(groups[i]); + + var proto = new textsecure.protobuf.PushMessageContent(); + proto.group = new textsecure.protobuf.PushMessageContent.GroupContext(); + + proto.group.id = toArrayBuffer(group.id); + proto.group.type = textsecure.protobuf.PushMessageContent.GroupContext.Type.UPDATE; + proto.group.members = group.numbers; + proto.group.name = group.name === undefined ? null : group.name; + + if (group.avatar !== undefined) { + return makeAttachmentPointer(group.avatar).then(function(attachment) { + proto.group.avatar = attachment; + promises.push(sendGroupProto([number], proto)); + }); + } else { + promises.push(sendGroupProto([number], proto)); + } + } + return Promise.all(promises); + } + + var tryMessageAgain = function(number, encodedMessage, message_id) { + var message = new Whisper.MessageCollection().add({id: message_id}); + message.fetch().then(function() { + textsecure.storage.removeEncrypted("devices" + number); + var proto = textsecure.protobuf.PushMessageContent.decode(encodedMessage, 'binary'); + sendMessageProto(message.get('sent_at'), [number], proto, function(res) { + if (res.failure.length > 0) { + message.set('errors', res.failure); + } + else { + message.set('errors', []); + } + message.save().then(function(){ + extension.trigger('message', message); // notify frontend listeners + }); + }); + }); + }; + textsecure.replay.registerFunction(tryMessageAgain, textsecure.replay.Type.SEND_MESSAGE); + + var sendMessageProto = function(timestamp, 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) { + if (error.humanError) + message = error.humanError; + } else + error = new Error(message); + 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) + return registerError(number, "Got empty device list when loading device keys", null); + refreshGroups(number).then(function() { + doSendMessage(number, devicesForNumber, recurse); + }); + } + } + + doSendMessage = function(number, devicesForNumber, recurse) { + return sendMessageToDevices(timestamp, 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 = new textsecure.OutgoingIdentityKeyError(number, getString(message.encode())); + registerError(number, "Identity key changed", error); + } + }); + } else + registerError(number, "Failed to create or send message", error); + }); + } + + _.each(numbers, function(number) { + var devicesForNumber = textsecure.storage.devices.getDeviceObjectsForNumber(number); + + var promises = []; + for (var j in devicesForNumber) + if (devicesForNumber[j].registrationId === undefined) + promises[promises.length] = getKeysForNumber(number, [parseInt(textsecure.utils.unencodeNumber(devicesForNumber[j].encodedNumber)[1])]); + + Promise.all(promises).then(function() { + 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); + }); + }); + } + + makeAttachmentPointer = function(attachment) { + var proto = new textsecure.protobuf.PushMessageContent.AttachmentPointer(); + proto.key = textsecure.crypto.getRandomBytes(64); + + var iv = textsecure.crypto.getRandomBytes(16); + return textsecure.protocol.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; + }); + }); + } + + var sendIndividualProto = function(number, proto, timestamp) { + return new Promise(function(resolve, reject) { + sendMessageProto(timestamp, [number], proto, function(res) { + if (res.failure.length > 0) + reject(res.failure); + else + resolve(); + }); + }); + } + + sendGroupProto = function(numbers, proto, timestamp) { + timestamp = timestamp || Date.now(); + var me = textsecure.utils.unencodeNumber(textsecure.storage.getUnencrypted("number_id"))[0]; + numbers = numbers.filter(function(number) { return number != me; }); + + return new Promise(function(resolve, reject) { + sendMessageProto(timestamp, numbers, proto, function(res) { + if (res.failure.length > 0) + reject(res.failure); + else + resolve(); + }); + }); + } + + self.sendMessageToNumber = function(number, messageText, attachments, timestamp) { + var proto = new textsecure.protobuf.PushMessageContent(); + proto.body = messageText; + + var promises = []; + for (var i in attachments) + promises.push(makeAttachmentPointer(attachments[i])); + return Promise.all(promises).then(function(attachmentsArray) { + proto.attachments = attachmentsArray; + return sendIndividualProto(number, proto, timestamp); + }); + } + + self.closeSession = function(number) { + var proto = new textsecure.protobuf.PushMessageContent(); + proto.body = "TERMINATE"; + proto.flags = textsecure.protobuf.PushMessageContent.Flags.END_SESSION; + return sendIndividualProto(number, proto).then(function(res) { + var devices = textsecure.storage.devices.getDeviceObjectsForNumber(number); + for (var i in devices) + textsecure.protocol.closeOpenSessionForDevice(devices[i].encodedNumber); + + return res; + }); + } + + self.sendMessageToGroup = function(groupId, messageText, attachments, timestamp) { + var proto = new textsecure.protobuf.PushMessageContent(); + proto.body = messageText; + proto.group = new textsecure.protobuf.PushMessageContent.GroupContext(); + proto.group.id = toArrayBuffer(groupId); + proto.group.type = textsecure.protobuf.PushMessageContent.GroupContext.Type.DELIVER; + + var numbers = textsecure.storage.groups.getNumbers(groupId); + if (numbers === undefined) + return new Promise(function(resolve, reject) { reject(new Error("Unknown Group")); }); + + var promises = []; + for (var i in attachments) + promises.push(makeAttachmentPointer(attachments[i])); + return Promise.all(promises).then(function(attachmentsArray) { + proto.attachments = attachmentsArray; + return sendGroupProto(numbers, proto, timestamp); + }); + } + + self.createGroup = function(numbers, name, avatar) { + var proto = new textsecure.protobuf.PushMessageContent(); + proto.group = new textsecure.protobuf.PushMessageContent.GroupContext(); + + var group = textsecure.storage.groups.createNewGroup(numbers); + proto.group.id = toArrayBuffer(group.id); + var numbers = group.numbers; + + proto.group.type = textsecure.protobuf.PushMessageContent.GroupContext.Type.UPDATE; + proto.group.members = numbers; + proto.group.name = name; + + if (avatar !== undefined) { + return makeAttachmentPointer(avatar).then(function(attachment) { + proto.group.avatar = attachment; + return sendGroupProto(numbers, proto).then(function() { + return proto.group.id; + }); + }); + } else { + return sendGroupProto(numbers, proto).then(function() { + return proto.group.id; + }); + } + } + + self.addNumberToGroup = function(groupId, number) { + var proto = new textsecure.protobuf.PushMessageContent(); + proto.group = new textsecure.protobuf.PushMessageContent.GroupContext(); + proto.group.id = toArrayBuffer(groupId); + proto.group.type = textsecure.protobuf.PushMessageContent.GroupContext.Type.UPDATE; + + var numbers = textsecure.storage.groups.addNumbers(groupId, [number]); + if (numbers === undefined) + return new Promise(function(resolve, reject) { reject(new Error("Unknown Group")); }); + proto.group.members = numbers; + + return sendGroupProto(numbers, proto); + } + + self.setGroupName = function(groupId, name) { + var proto = new textsecure.protobuf.PushMessageContent(); + proto.group = new textsecure.protobuf.PushMessageContent.GroupContext(); + proto.group.id = toArrayBuffer(groupId); + proto.group.type = textsecure.protobuf.PushMessageContent.GroupContext.Type.UPDATE; + proto.group.name = name; + + var numbers = textsecure.storage.groups.getNumbers(groupId); + if (numbers === undefined) + return new Promise(function(resolve, reject) { reject(new Error("Unknown Group")); }); + proto.group.members = numbers; + + return sendGroupProto(numbers, proto); + } + + self.setGroupAvatar = function(groupId, avatar) { + var proto = new textsecure.protobuf.PushMessageContent(); + proto.group = new textsecure.protobuf.PushMessageContent.GroupContext(); + proto.group.id = toArrayBuffer(groupId); + proto.group.type = textsecure.protobuf.PushMessageContent.GroupContext.Type.UPDATE; + + var numbers = textsecure.storage.groups.getNumbers(groupId); + if (numbers === undefined) + return new Promise(function(resolve, reject) { reject(new Error("Unknown Group")); }); + proto.group.members = numbers; + + return makeAttachmentPointer(avatar).then(function(attachment) { + proto.group.avatar = attachment; + return sendGroupProto(numbers, proto); + }); + } + + self.leaveGroup = function(groupId) { + var proto = new textsecure.protobuf.PushMessageContent(); + proto.group = new textsecure.protobuf.PushMessageContent.GroupContext(); + proto.group.id = toArrayBuffer(groupId); + proto.group.type = textsecure.protobuf.PushMessageContent.GroupContext.Type.QUIT; + + var numbers = textsecure.storage.groups.getNumbers(groupId); + if (numbers === undefined) + return new Promise(function(resolve, reject) { reject(new Error("Unknown Group")); }); + textsecure.storage.groups.deleteGroup(groupId); + + return sendGroupProto(numbers, proto); + } + + return self; +}(); diff --git a/js/api.js b/libtextsecure/api.js similarity index 100% rename from js/api.js rename to libtextsecure/api.js diff --git a/js/crypto.js b/libtextsecure/crypto.js similarity index 100% rename from js/crypto.js rename to libtextsecure/crypto.js diff --git a/js/errors.js b/libtextsecure/errors.js similarity index 100% rename from js/errors.js rename to libtextsecure/errors.js diff --git a/js/helpers.js b/libtextsecure/helpers.js similarity index 100% rename from js/helpers.js rename to libtextsecure/helpers.js diff --git a/js/protobufs.js b/libtextsecure/protobufs.js similarity index 100% rename from js/protobufs.js rename to libtextsecure/protobufs.js diff --git a/js/protocol.js b/libtextsecure/protocol.js similarity index 100% rename from js/protocol.js rename to libtextsecure/protocol.js diff --git a/js/sendmessage.js b/libtextsecure/sendmessage.js similarity index 100% rename from js/sendmessage.js rename to libtextsecure/sendmessage.js diff --git a/js/storage.js b/libtextsecure/storage.js similarity index 100% rename from js/storage.js rename to libtextsecure/storage.js diff --git a/js/storage/devices.js b/libtextsecure/storage/devices.js similarity index 100% rename from js/storage/devices.js rename to libtextsecure/storage/devices.js diff --git a/js/storage/groups.js b/libtextsecure/storage/groups.js similarity index 100% rename from js/storage/groups.js rename to libtextsecure/storage/groups.js diff --git a/js/stringview.js b/libtextsecure/stringview.js similarity index 100% rename from js/stringview.js rename to libtextsecure/stringview.js diff --git a/js/websocket-resources.js b/libtextsecure/websocket-resources.js similarity index 100% rename from js/websocket-resources.js rename to libtextsecure/websocket-resources.js diff --git a/js/websocket.js b/libtextsecure/websocket.js similarity index 100% rename from js/websocket.js rename to libtextsecure/websocket.js diff --git a/options.html b/options.html index 2bcd98e90..b56dbd2b4 100644 --- a/options.html +++ b/options.html @@ -95,27 +95,17 @@ - - - - - - - - - - - - - + + + + + - - diff --git a/test/index.html b/test/index.html index 9bd8e99b0..ac5de142d 100644 --- a/test/index.html +++ b/test/index.html @@ -125,27 +125,18 @@ - - - - - - - - - - + + - - + + + + - - -