From 136da2894c13b078fe868327a8d4c2174eba7d55 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Wed, 15 Jan 2014 02:46:05 -0500 Subject: [PATCH] Entirely untested plane work. --- IncomingPushMessageSignal.proto | 25 +++ OutgoingMessageSignal.proto | 29 --- WhisperTextProtocol.proto | 25 +++ helpers.js | 326 ++++++++++++++++++++++++++++---- options.js | 2 +- 5 files changed, 339 insertions(+), 68 deletions(-) create mode 100644 IncomingPushMessageSignal.proto delete mode 100644 OutgoingMessageSignal.proto create mode 100644 WhisperTextProtocol.proto diff --git a/IncomingPushMessageSignal.proto b/IncomingPushMessageSignal.proto new file mode 100644 index 000000000..a86c2e1a4 --- /dev/null +++ b/IncomingPushMessageSignal.proto @@ -0,0 +1,25 @@ +package textsecure; + +option java_package = "org.whispersystems.textsecure.push"; +option java_outer_classname = "PushMessageProtos"; + +message IncomingPushMessageSignal { + optional uint32 type = 1; + optional string source = 2; + optional string relay = 3; + repeated string destinations = 4; + optional uint64 timestamp = 5; + optional bytes message = 6; // Contains an encrypted PushMessageContent +} + +message PushMessageContent { + optional string body = 1; + + message AttachmentPointer { + optional fixed64 id = 1; + optional string contentType = 2; + optional bytes key = 3; + } + + repeated AttachmentPointer attachments = 2; +} \ No newline at end of file diff --git a/OutgoingMessageSignal.proto b/OutgoingMessageSignal.proto deleted file mode 100644 index 06cf14600..000000000 --- a/OutgoingMessageSignal.proto +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Copyright (C) 2013 Open WhisperSystems - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero 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 Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -package textsecure; - -option java_package = "org.whispersystems.textsecuregcm.entities"; -option java_outer_classname = "MessageProtos"; - -message OutgoingMessageSignal { - optional uint32 type = 1; - optional string source = 2; - optional string relay = 3; - repeated string destinations = 4; - optional uint64 timestamp = 5; - optional bytes message = 6; -} \ No newline at end of file diff --git a/WhisperTextProtocol.proto b/WhisperTextProtocol.proto new file mode 100644 index 000000000..f14625b96 --- /dev/null +++ b/WhisperTextProtocol.proto @@ -0,0 +1,25 @@ +package textsecure; + +option java_package = "org.whispersystems.textsecure.crypto.protocol"; +option java_outer_classname = "WhisperProtos"; + +message WhisperMessage { + optional bytes ephemeralKey = 1; + optional uint32 counter = 2; + optional uint32 previousCounter = 3; + optional bytes ciphertext = 4; +} + +message PreKeyWhisperMessage { + optional uint32 preKeyId = 1; + optional bytes baseKey = 2; + optional bytes identityKey = 3; + optional bytes message = 4; // WhisperMessage +} + +message KeyExchangeMessage { + optional uint32 id = 1; + optional bytes baseKey = 2; + optional bytes ephemeralKey = 3; + optional bytes identityKey = 4; +} diff --git a/helpers.js b/helpers.js index d19ff6c02..2f128319c 100644 --- a/helpers.js +++ b/helpers.js @@ -34,9 +34,17 @@ function base64DecToArr (sBase64, nBlocksSize) { return taBytes; } +/**************************** + *** Forward declarations *** + ****************************/ +var crypto = {}; +crypto._storage = {}; +var storage = {}; + /********************************* *** Type conversion utilities *** *********************************/ +// Strings/arrays var StaticByteBufferProto = new dcodeIO.ByteBuffer().__proto__; var StaticUint8ArrayProto = new Uint8Array().__proto__; function getString(thing) { @@ -55,11 +63,34 @@ function base64ToUint8Array(string) { return base64DecToArr(string); } -var OutgoingMessageProtobuf = dcodeIO.ProtoBuf.loadProtoFile("OutgoingMessageSignal.proto").build("textsecure.OutgoingMessageSignal"); -function decodeProtobuf(string) { - return OutgoingMessageProtobuf.decode(string); +// Protobuf decodingA +//TODO: throw on missing fields everywhere +var IncomingPushMessageProtobuf = dcodeIO.ProtoBuf.loadProtoFile("IncomingPushMessageSignal.proto").build("textsecure.IncomingPushMessageSignal"); +function decodeIncomingPushMessageProtobuf(string) { + return IncomingPushMessageProtobuf.decode(btoa(string)); +} + +var PushMessageContentProtobuf = dcodeIO.ProtoBuf.loadProtoFile("IncomingPushMessageSignal.proto").build("textsecure.PushMessageContent"); +function decodePPushMessageContentProtobuf(string) { + return PushMessageContentProtobuf.decode(btoa(string)); +} + +var WhisperMessageProtobuf = dcodeIO.ProtoBuf.loadProtoFile("WhisperTextProtocol.proto").build("textsecure.WhisperMessage"); +function decodeWhisperMessageProtobuf(string) { + return WhisperMessageProtobuf.decode(btoa(string)); +} + +var PreKeyWhisperMessageProtobuf = dcodeIO.ProtoBuf.loadProtoFile("WhisperTextProtocol.proto").build("textsecure.PreKeyWhisperMessage"); +function decodePreKeyWhisperMessageProtobuf(string) { + return PreKeyWhisperMessageProtobuf.decode(btoa(string)); +} + +var KeyExchangeMessageProtobuf = dcodeIO.ProtoBuf.loadProtoFile("WhisperTextProtocol.proto").build("textsecure.KeyExchangeMessage"); +function decodeKeyExchangeMessageProtobuf(string) { + return KeyExchangeMessageProtobuf.decode(btoa(string)); } +// Number formatting function getNumberFromString(string) { return string.split(".")[0]; } @@ -82,17 +113,26 @@ function getDeviceId(encodedNumber) { return 1; } +// Other + function timestampToHumanReadable(timestamp) { var date = new Date(); date.setTime(timestamp*1000); return date.toUTCString(); } +function objectContainsKeys(object) { + var count = 0; + for (key in object) { + count++; + break; + } + return count != 0; +} + /************************************************ *** Utilities to store data in local storage *** ************************************************/ -var storage = {}; - storage.putEncrypted = function(key, value) { //TODO if (value === undefined) @@ -108,6 +148,10 @@ storage.getEncrypted = function(key, defaultValue) { return JSON.parse(value); } +storage.removeEncrypted = function(key) { + //TODO +} + storage.putUnencrypted = function(key, value) { if (value === undefined) throw "Tried to store undefined"; @@ -121,6 +165,10 @@ storage.getUnencrypted = function(key, defaultValue) { return JSON.parse(value); } +storage.removeUnencrypted = function(key) { + //TODO +} + function registrationDone() { storage.putUnencrypted("registration_done", ""); } @@ -204,50 +252,202 @@ function getRandomBytes(size) { return array; } catch (err) { //TODO: ummm...wat? + throw err; } } -function getNewPubKey(keyName) { +crypto._createNewKeyPair = function() { //TODO var pubKey = "BRTJzsHPUWRRBxyo5MoaBRidMk2fwDlfqvU91b6pzbED"; var privKey = ""; - storage.putEncrypted("pubKey" + keyName, pubKey); - storage.putEncrypted("privKey" + keyName, privKey); - return pubKey; + return { pubKey: pubKey, privKey: privKey }; } -function getExistingPubKey(keyName) { - return storage.getEncrypted("pubKey" + keyName); +crypto._storage.getNewPubKeySTORINGPrivKey = function(keyName) { + var keyPair = _createNewKeyPair(); + storage.putEncrypted("25519Key" + keyName, keyPair); + return keyPair.pubKey; } -function generateKeys() { - var identityKey = getExistingPubKey("identityKey"); - if (identityKey === undefined) - identityKey = getNewPubKey("identityKey"); +crypto._storage.getStoredPubKey = function(keyName) { + return storage.getEncrypted("25519Key" + keyName, { pubKey: undefined }).pubKey; +} - var keyGroupId = storage.getEncrypted("lastKeyGroupId", -1) + 1; - storage.putEncrypted("lastKeyGroupId", keyGroupId); +crypto._storage.getStoredKeyPair = function(keyName) { + return storage.getEncrypted("25519Key" + keyName); +} - var keys = {}; - keys.keys = []; - for (var i = 0; i < 100; i++) - keys.keys[i] = {keyId: i, publicKey: getNewPubKey("key" + keyGroupId + i), identityKey: identityKey}; - // 0xFFFFFF == 16777215 - keys.lastResortKey = {keyId: 16777215, publicKey: getNewPubKey("lastResortKey" + keyGroupId), identityKey: identityKey}; - return keys; +crypto._storage.getAndRemoveStoredKeyPair = function(keyName) { + var keyPair = getStoredKeyPair(keyName); + storage.removeEncrypted("25519Key" + keyName); + return keyPair; +} + +crypto._storage.getAndRemovePreKeyPair = function(keyId) { + return getAndRemoveStoredKeyPair("preKey" + keyId); +} + +crypto._storage.getIdentityPrivKey = function() { + return getStoredKeyPair("identityKey").privKey; +} + +crypto._storage.saveSession = function(encodedNumber, session) { + storage.putEncrypted("session" + getEncodedNumber(encodedNumber), session); +} + +crypto._storage.getSession = function(encodedNumber) { + return storage.getEncrypted("session" + getEncodedNumber(encodedNumber)); +} + + +/***************************** + *** Internal Crypto stuff *** + *****************************/ +crypto._ECDHE = function(pubKey, privKey) { + return "ECDHE";//TODO +} + +crypto._HKDF = function(input, salt, info) { + var hkdf = "HKDF(" + input + ", " + salt + ", " + info + ")"; //TODO + return [ hkdf.substring(0, 32), hkdf.substring(32, 64) ]; +} + +crypto._HMACSHA256 = function(input, key) { + //TODO: NativeA + //TODO: return string + return CryptoJS.HmacSHA256(input, CryptoJS.enc.Latin1.parse(getString(key))); +} + +crypto._verifyMACWithVersionByte = function(data, key, mac) { + var calculated_mac = CryptoJS.algo.HMAC.create(CryptoJS.algo.SHA256, key); + calculated_mac.update(CryptoJS.enc.Latin1.parse(String.fromCharCode(1))); + calculated_mac.update(CryptoJS.enc.Latin1.parse(getString(data))); + calculated_mac = calculated_mac.finalize(); + + if (btoa(calculated_mac.toString(CryptoJS.enc.Base64)).substring(0, mac.length) != mac) { + console.log("Got message with bad MAC"); + throw "Bad MAC"; + } +} + +/****************************** + *** Ratchet implementation *** + ******************************/ +crypto._initSession = function(isInitiator, theirIdentityPubKey, ourEphemeralPrivKey, theirEphemeralPubKey) { + var ourIdentityPrivKey = _storage.getIdentityPrivKey(); + + var sharedSecret = _ECDHE(theirEphemeralPubKey, ourIdentityPrivKey); + if (isInitiator) + sharedSecret = sharedSecret + _ECDHE(theirIdentityPubKey, ourEphemeralPrivKey); + else + sharedSecret = _ECDHE(theirIdentityPubKey, ourEphemeralPrivKey) + sharedSecret; + sharedSecret += _ECDHE(theirEphemeralPubKey, ourEphemeralPrivKey); + + var masterKey = _HKDF(sharedSecret, '', "WhisperText"); + return { rootKey: masterKey[0], chainKey: masterKey[1] }; +} + +crypto._initSessionFromPreKeyWhisperMessage = function(encodedNumber, message) { + //TODO: Check remote identity key matches known-good key + + var preKeyPair = _storage.getAndRemovePreKeyPair(preKeyProto.preKeyId); + if (preKeyPair === undefined) + throw "Missing preKey for PreKeyWhisperMessage"; + + var firstRatchet = _initSession(false, message.identityKey, preKeyPair.privKey, message.baseKey); + + var session = {currentRatchet: { rootKey: firstRatchet.rootKey, ephemeralKeyPair: preKeyPair, + lastRemoteEphemeralKey: message.baseKey }, + oldRatchetList: [] + }; + session[preKeyPair.pubKey] = { messageKeys: {}, chainKey: { counter: 0, key: firstRatchet.chainKey } }; + _storage.saveSession(encodedNumber, session); } -/******************** - *** Crypto stuff *** - ********************/ +crypto._fillMessageKeys = function(chain, counter) { + var messageKeys = chain.messageKeys; + var key = chain.chainKey.key; + for (var i = chain.chainKey.counter; i < counter; i++) { + messageKeys[counter] = _HMACSHA256(key, String.fromCharCode(1)); + key = _HMACSHA256(key, String.fromCharCode(2)); + } + chain.chainKey.key = key; + chain.chainKey.counter = counter; +} + +crypto._maybeStepRatchet = function(session, remoteKey, previousCounter) { + if (sesion[remoteKey] !== undefined) //TODO: null??? + return; + + var ratchet = session.currentRatchet; + + var previousRatchet = session[ratchet.lastRemoteEphemeralKey]; + _fillMessageKeys(previousRatchet, previousCounter); + if (!objectContainsKeys(previousRatchet.messageKeys)) + delete session[ratchet.lastRemoteEphemeralKey]; + else + session.oldRatchetList[session.oldRatchetList.length] = { added: new Date().getTime(), ephemeralKey: ratchet.lastRemoteEphemeralKey }; + + delete session[ratchet.ephemeralKeyPair.pubKey]; + + var masterKey = _HKDF(_ECDHE(remoteKey, ratchet.ephemeralKeyPair.privKey), ratchet.rootKey, "WhisperRatchet"); + session[remoteKey] = { messageKeys: {}, chainKey: { counter: 0, key: masterKey.substring(32, 64) } }; + + ratchet.ephemeralKeyPair = _createNewKeyPair(); + masterKey = _HKDF(_ECDHE(remoteKey, ratchet.ephemeralKeyPair.privKey), masterKey.substring(0, 32), "WhisperRatchet"); + ratchet.rootKey = masterKey.substring(0, 32); + session[nextRatchet.ephemeralKeyPair.pubKey] = { messageKeys: {}, chainKey: { counter: 0, key: masterKey.substring(32, 64) } }; + + ratchet.lastRemoteEphemeralKey = remoteKey; +} + +crypto._doDecryptWhisperMessage = function(ciphertext, mac, messageKey, counter) { + //TODO keys swapped? + var keys = _HKDF(messageKey, /* all 0x00 bytes????? */ '', "WhisperMessageKeys"); + _verifyMACWithVersionByte(ciphertext, keys[0], mac); + + return AES_CTR_NOPADDING(keys[1], CTR = counter, ciphertext); +} + +// returns decrypted protobuf +crypto._decryptWhisperMessage = function(encodedNumber, messageBytes) { + var session = _storage.getSession(encodedNumber); + if (session === undefined) + throw "No session currently open with " + encodedNumber; + + if (messageBytes[0] != String.fromCharCode(1)) + throw "Bad version number on WhisperMessage"; + + var messageProto = messageBytes.substring(1, messageBytes.length - 8); + var mac = messageBytes.substring(messageBytes.length - 8, messageBytes.length); + + var message = decodeWhisperMessageProtobuf(messageProto); + + _maybeStepRatchet(session, getString(message.ephemeralKey), message.previousCounter); + var chain = session[getString(message.ephemeralKey)]; + + _fillMessageKeys(chain, message.counter); -// Decrypts message into a BASE64 string -function decryptWebsocketMessage(message) { + var plaintext = _doDecryptWhisperMessage(message.ciphertext, mac, chain.messageKeys[message.counter], message.counter); + delete chain.messageKeys[message.counter]; + + _removeOldChains(session); + + _storage.saveSession(encodedNumber, session); + return decodeWhisperMessage(atob(plaintext)); +} + +/************************* + *** Public crypto API *** + *************************/ +// Decrypts message into a raw string +crypto.decryptWebsocketMessage = function(message) { //TODO: Use a native AES impl (so I dont feel so bad about side-channels) - var signaling_key = storage.getEncrypted("signaling_key"); - var aes_key = CryptoJS.enc.Latin1.parse(signaling_key.substring(0, 32)); + var signaling_key = storage.getEncrypted("signaling_key"); //TODO: in crypto._storage + var aes_key = CryptoJS.enc.Latin1.parse(signaling_key.substring(0, 32));//TODO: UTF8 breaks this????? var mac_key = CryptoJS.enc.Latin1.parse(signaling_key.substring(32, 32 + 20)); + //TODO: Can we drop the uint8array in favor of raw strings? var decodedMessage = base64ToUint8Array(message); if (decodedMessage[0] != 1) { console.log("Got bad version number: " + decodedMessage[0]); @@ -263,21 +463,60 @@ function decryptWebsocketMessage(message) { calculated_mac.update(CryptoJS.lib.WordArray.create(ciphertext)); calculated_mac = calculated_mac.finalize(); - var plaintext = CryptoJS.AES.decrypt(btoa(getString(ciphertext)), aes_key, {iv: iv});//TODO: Does this throw on invalid padding? - if (calculated_mac.toString(CryptoJS.enc.Hex).substring(0, 20) != mac.toString(CryptoJS.enc.Hex)) { console.log("Got message with bad MAC"); throw "Bad MAC"; } - return plaintext.toString(CryptoJS.enc.Base64); + var plaintext = CryptoJS.AES.decrypt(btoa(getString(ciphertext)), aes_key, {iv: iv});//TODO: Does this throw on invalid padding (seems not...) + + return atob(plaintext.toString(CryptoJS.enc.Base64)); +} + +crypto.handleIncomingPushMessageProto = function(proto) { + switch(proto.type) { + case 0: //TYPE_MESSAGE_PLAINTEXT + proto.message = decodePushMessageContent(toString(proto.message)); + break; + case 1: //TYPE_MESSAGE_CIPHERTEXT + proto.message = _decryptWhisperMessage(proto.source, toString(proto.message)); + break; + case 3: //TYPE_MESSAGE_PREKEY_BUNDLE + var preKeyProto = decodePreKeyWhisperMessageProtobuf(toString(proto.message)); + _initSessionFromPreKeyWhisperMessage(proto.source, preKeyProto); + proto.message = _decryptWhisperMessage(proto.source, toString(preKeyProto.message)); + break; + } } -function encryptMessageFor(deviceObject, message) { +crypto.encryptMessageFor = function(deviceObject, message) { return message + " encrypted to " + deviceObject.encodedNumber + " with relay " + deviceObject.relay + " with identityKey " + deviceObject.identityKey + " and public key " + deviceObject.publicKey; //TODO } +var GENERATE_KEYS_KEYS_GENERATED = 100; +crypto.generateKeys = function() { + var identityKey = _storage.getStoredPubKey("identityKey"); + if (identityKey === undefined) + identityKey = _storage.getNewPubKeySTORINGPrivKey("identityKey"); //TODO: should probably just throw? + + var firstKeyId = storage.getEncrypted("maxPreKeyId", -1) + 1; + storage.putEncrypted("maxPreKeyId", firstKeyId + GENERATE_KEYS_KEYS_GENERATED); + + if (firstKeyId > 16777000) + throw "You crazy motherfucker"; + + var keys = {}; + keys.keys = []; + for (var i = firstKeyId; i < firstKeyId + GENERATE_KEYS_KEYS_GENERATED; i++) + keys.keys[i] = {keyId: i, publicKey: _storage.getNewPubKeySTORINGPrivKey("preKey" + i), identityKey: identityKey}; + // 0xFFFFFF == 16777215 + keys.lastResortKey = {keyId: 16777215, publicKey: _storage.getStoredPubKey("preKey16777215"), identityKey: identityKey};//TODO: Rotate lastResortKey + if (keys.lastResortKey.publicKey === undefined) + keys.lastResortKey.publicKey = _storage.getNewPubKeySTORINGPrivKey("preKey16777215"); + return keys; +} + /************************************************ *** Utilities to communicate with the server *** ************************************************/ @@ -356,14 +595,25 @@ function subscribeToPush(message_callback) { return; } + var proto; try { - var plaintext = decryptWebsocketMessage(message.message); - var proto = decodeProtobuf(plaintext); + var plaintext = crypto.decryptWebsocketMessage(message.message); + var proto = decodeIncomingPushMessageProtobuf(plaintext); + // After this point, a) decoding errors are not the server's fault, and + // b) we should handle them gracefully and tell the user they received an invalid message doAjax({call: 'push', httpType: 'PUT', urlParameters: '/' + message.id, do_auth: true}); - message_callback(proto); } catch (e) { console.log("Error decoding message: " + e); + return; + } + + try { + crypto.handleIncomingPushMessageProto(proto); // Decrypts/decodes/fills in fields/etc + + message_callback(proto); + } catch (e) { + //TODO: Tell the user decryption failed } }, onError: function(response) { diff --git a/options.js b/options.js index 58265d2bf..8a74e1457 100644 --- a/options.js +++ b/options.js @@ -48,7 +48,7 @@ $('#init-go').click(function() { subscribeToPush(function(message) { //TODO receive spuhared identity key $('#verify2done').html('done'); - var keys = generateKeys(); + var keys = crypto.generateKeys(); $('#verify3done').html('done'); doAjax({call: 'keys', httpType: 'PUT', do_auth: true, jsonData: keys, success_callback: function(response) {