From 9e3257c3bb9da9e6d6d92d9416e879c1a010356e Mon Sep 17 00:00:00 2001 From: Joachim Bauch Date: Sat, 8 Aug 2015 01:36:41 +0200 Subject: [PATCH] Add new encryption service using libaxolotl-javascript. --- static/js/services/encryption.js | 700 +++++++++++++++++++++++++++++++ static/js/services/services.js | 9 +- 2 files changed, 706 insertions(+), 3 deletions(-) create mode 100644 static/js/services/encryption.js diff --git a/static/js/services/encryption.js b/static/js/services/encryption.js new file mode 100644 index 00000000..2f8439b3 --- /dev/null +++ b/static/js/services/encryption.js @@ -0,0 +1,700 @@ +/* + * Spreed WebRTC. + * Copyright (C) 2013-2015 struktur AG + * + * This file is part of Spreed WebRTC. + * + * 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 . + * + */ +"use strict"; +define([ + 'jquery', + 'underscore', + 'modernizr', + 'axolotl', + 'ByteBuffer' +], function( + $, + _, + modernizr, + axolotl, + ByteBuffer +) { + + return [ + "$window", + "$q", + function( + $window, + $q + ) { + + // Bitflags for the different components that need to be ready for + // encryption to work. + var READY_NONE = 0; + var READY_IDENTITY_KEYPAIR = 1; + var READY_REGISTRATION_ID = 2; + var READY_LAST_RESORT_PRE_KEY = 4; + + var READY_ALL = READY_IDENTITY_KEYPAIR | + READY_REGISTRATION_ID | + READY_LAST_RESORT_PRE_KEY; + + var LAST_RESORT_PRE_KEY_ID = 0xffffffff; + + var deserializeKeypair = function(data) { + if (typeof(data) === "string") { + data = JSON.parse(data); + } + return { + "private": ByteBuffer.fromBase64(data.private).toArrayBuffer(), + "public": ByteBuffer.fromBase64(data.public).toArrayBuffer() + }; + }; + + var deserializePublicKey = function(data) { + return ByteBuffer.fromBase64(data).toArrayBuffer(); + }; + + var deserializeSignature = function(data) { + return ByteBuffer.fromBase64(data).toArrayBuffer(); + }; + + var deserializeSignedPreKey = function(data) { + if (typeof(data) === "string") { + data = JSON.parse(data); + } + return { + "id": parseInt(data.id, 10), + "keyPair": { + "private": + ByteBuffer.fromBase64(data.private).toArrayBuffer(), + "public": + ByteBuffer.fromBase64(data.public).toArrayBuffer() + }, + "signature": + ByteBuffer.fromBase64(data.signature).toArrayBuffer() + }; + }; + + var serializeKeypair = function(keypair) { + var data = { + "private": ByteBuffer.wrap(keypair.private).toBase64(), + "public": ByteBuffer.wrap(keypair.public).toBase64() + }; + return JSON.stringify(data); + }; + + var serializePublicKey = function(keypair) { + return ByteBuffer.wrap(keypair.public).toBase64(); + }; + + var serializeSignature = function(signature) { + return ByteBuffer.wrap(signature).toBase64(); + }; + + var serializeSignedPreKey = function(key) { + var data = { + "id": key.id, + "private": ByteBuffer.wrap(key.keyPair.private).toBase64(), + "public": ByteBuffer.wrap(key.keyPair.public).toBase64(), + "signature": ByteBuffer.wrap(key.signature).toBase64() + }; + return JSON.stringify(data); + }; + + var LocalStorageDataStore = function(encryption) { + this.e = $({}); + this.encryption = encryption; + this.userid = null; + this.id = encryption.api.sid; + this.anonymous_storage = {}; + encryption.api.e.on("received.self", _.bind(function(event, data) { + var userid = data.Suserid || null; + if (this.id === (userid || data.Sid) && this.userid === userid) { + return; + } + var initial = (this.id === null); + this.id = userid || data.Sid; + if (this.userid !== userid) { + this.userid = userid; + this.anonymous_storage = {}; + } + this.encryption.resetStore(); + if (initial) { + this.e.triggerHandler("ready"); + } + }, this)); + this.storage = $window.localStorage; + }; + + LocalStorageDataStore.prototype.isReady = function() { + return !!this.id; + }; + + LocalStorageDataStore.prototype.load = function(key) { + if (!this.id) { + // Not connected, unknown user state. + return null; + } else if (!this.userid) { + // Connected, but not logged in. + return this.anonymous_storage[key] || null; + } else { + // Connected, logged in. + return this.storage.getItem(key); + } + }; + + LocalStorageDataStore.prototype.save = function(key, data) { + if (!this.id) { + return false; + } else if (!this.userid) { + this.anonymous_storage[key] = data; + return true; + } + switch (typeof(data)) { + case "string": + break; + case "number": + data = data.toString(); + break; + default: + data = JSON.stringify(data); + } + this.storage.setItem(key, data); + return true; + }; + + LocalStorageDataStore.prototype.loadKeypair = function() { + var data = this.load("keypair_" + this.id); + if (!data) { + return null; + } + + return deserializeKeypair(data); + }; + + LocalStorageDataStore.prototype.loadRegistrationId = function() { + var data = this.load("registration_id_" + this.id); + if (!data) { + return null; + } + + return parseInt(data, 10); + }; + + LocalStorageDataStore.prototype.loadNextSignedPreKeyId = function() { + var data = this.load("next_signed_pre_key_id_" + this.id); + if (!data) { + return null; + } + + return parseInt(data, 10); + }; + + LocalStorageDataStore.prototype.loadSignedPreKey = function(id) { + var data = this.load("signed_pre_key_" + this.id + "_" + id); + if (!data) { + return null; + } + + return deserializeSignedPreKey(data); + }; + + LocalStorageDataStore.prototype.saveKeypair = function(keypair) { + var data = serializeKeypair(keypair); + return this.save("keypair_" + this.id, data); + }; + + LocalStorageDataStore.prototype.saveRegistrationId = function(id) { + return this.save("registration_id_" + this.id, id); + }; + + LocalStorageDataStore.prototype.saveNextSignedPreKeyId = function(id) { + return this.save("next_pre_key_id_" + this.id, id); + }; + + LocalStorageDataStore.prototype.saveSignedPreKey = function(key) { + var data = serializeSignedPreKey(key); + return this.save("signed_pre_key_" + this.id + "_" + key.id, data); + }; + + var Encryption = function(api) { + this.e = $({}); + this.api = api; + // TODO(fancycode): Look into using IndexedDB as storage backend. + if (modernizr.localstorage) { + this.store = new LocalStorageDataStore(this); + } else { + // Need a way to store data for encryption support. + return; + } + + this.axol = axolotl({ + getLocalIdentityKeyPair: _.bind(function() { + return this.getLocalIdentityKeyPair.apply(this, arguments); + }, this), + getLocalRegistrationId: _.bind(function() { + return this.getLocalRegistrationId.apply(this, arguments); + }, this), + getLocalSignedPreKeyPair: _.bind(function() { + return this.getLocalSignedPreKeyPair.apply(this, arguments); + }, this), + getLocalPreKeyPair: _.bind(function() { + // Unsigned PreKeys are only used by protocol V2. + console.warn("Local PreKeys are not supported.") + return null; + }, this) + }); + this.api.e.on("received.requestkeybundle", + _.bind(function(event, peer) { + this.requestKeyBundle(peer); + }, this)) + this.api.e.on("received.keybundle", + _.bind(function(event, peer, data) { + this.receivedKeyBundle(peer, data); + }, this)) + this.peer_identities = {}; + this.sessions = {}; + this.resetStore(); + }; + + Encryption.prototype.isSupported = function() { + return !!this.store; + }; + + Encryption.prototype.resetStore = function() { + // TODO(fancycode): We should process any pending messages before + // resetting everything. + this.identity_keypair = null; + this.registration_id = null; + this.last_resort_pre_key = null; + this.next_signed_pre_key_id = 1; + this.pending_messages = {}; + this.is_processing_messages = {}; + this.ready_promise = null; + if (this.store.isReady()) { + _.defer(_.bind(function() { + this.register(); + }, this)); + } + }; + + Encryption.prototype.getIdentityFingerprint = function() { + return this.formatIdentityFingerprint(this.identity_keypair.public); + }; + + Encryption.prototype.formatIdentityFingerprint = function(public_key) { + // TODO(jojo): Change this to be the SHA-1 hash of a three-byte key + // id and the public key. + // See https://github.com/WhisperSystems/TextSecure/blob/08ed90c5ece49c92e35c492afb4e60160983015a/src/org/thoughtcrime/securesms/crypto/PublicKey.java#L95 + var fingerprint = ByteBuffer.wrap(public_key).toHex(); + var pos; + for (pos = fingerprint.length - 4; pos >= 4; pos -= 4) { + fingerprint = fingerprint.substr(0, pos) + "-" + + fingerprint.substr(pos); + } + return fingerprint.substr(2); + }; + + Encryption.prototype.storePeerIdentity = function(peer, public_key) { + var fingerprint = this.formatIdentityFingerprint(public_key); + var existing = this.peer_identities[peer]; + if (existing === fingerprint) { + return; + } else if (existing && existing !== fingerprint) { + console.warn("Fingerprint changed", { + "peer": peer, + "existing": existing, + "fingerprint": fingerprint + }); + } + this.peer_identities[peer] = fingerprint; + }; + + Encryption.prototype.getReadyPromise = function() { + if (!this.ready_promise) { + // Load/create initial objects + this.ready_promise = this.loadData().then(_.bind(function() { + var deferred = $q.defer(); + + _.defer(_.bind(function() { + var ready_state = READY_NONE; + var check_ready = _.bind(function() { + if (ready_state === READY_ALL) { + deferred.resolve(); + } + }, this); + this.getLocalIdentityKeyPair().then(function() { + ready_state |= READY_IDENTITY_KEYPAIR; + check_ready(); + }); + this.getLocalRegistrationId().then(function() { + ready_state |= READY_REGISTRATION_ID; + check_ready(); + }); + this.getLastResortPreKey().then(function() { + ready_state |= READY_LAST_RESORT_PRE_KEY; + check_ready(); + }); + }, this)); + return deferred.promise; + }, this)); + } + return this.ready_promise; + }; + + Encryption.prototype.loadData = function() { + var deferred = $q.defer(); + var doLoadData = _.bind(function() { + this.identity_keypair = this.store.loadKeypair(); + this.registration_id = this.store.loadRegistrationId(); + this.last_resort_pre_key = + this.store.loadSignedPreKey(LAST_RESORT_PRE_KEY_ID); + this.next_signed_pre_key_id = + this.store.loadNextSignedPreKeyId(); + if (this.next_signed_pre_key_id === null) { + this.next_signed_pre_key_id = 1; + } + deferred.resolve(); + }, this); + if (this.store.isReady()) { + _.defer(doLoadData); + } else { + this.store.e.one("ready", doLoadData); + } + return deferred.promise; + }; + + Encryption.prototype.getLocalIdentityKeyPair = function() { + var deferred = $q.defer(); + _.defer(_.bind(function() { + if (this.identity_keypair) { + deferred.resolve(this.identity_keypair); + return; + } + + this.axol.generateIdentityKeyPair() + .then(_.bind(function(keypair) { + if (this.identity_keypair === null) { + this.identity_keypair = keypair; + this.store.saveKeypair(keypair); + } + deferred.resolve(this.identity_keypair); + }, this)); + }, this)); + return deferred.promise; + }; + + Encryption.prototype.getLocalRegistrationId = function() { + var deferred = $q.defer(); + _.defer(_.bind(function() { + if (this.registration_id) { + deferred.resolve(this.registration_id); + return; + } + + this.axol.generateRegistrationId().then(_.bind(function(id) { + if (this.registration_id === null) { + this.registration_id = id; + this.store.saveRegistrationId(id); + } + deferred.resolve(this.registration_id); + }, this)); + }, this)); + return deferred.promise; + }; + + Encryption.prototype.getLastResortPreKey = function() { + var deferred = $q.defer(); + _.defer(_.bind(function() { + if (this.last_resort_pre_key) { + deferred.resolve(this.last_resort_pre_key); + return; + } + + this.getSignedPreKey(LAST_RESORT_PRE_KEY_ID) + .then(_.bind(function(pre_key) { + if (this.last_resort_pre_key === null) { + this.last_resort_pre_key = pre_key; + this.store.saveSignedPreKey(pre_key); + } + deferred.resolve(this.last_resort_pre_key); + }, this)); + }, this)); + return deferred.promise; + }; + + Encryption.prototype.getLocalSignedPreKeyPair = function(id) { + var deferred = $q.defer(); + _.defer(_.bind(function() { + var signed_pre_key; + if (id === LAST_RESORT_PRE_KEY_ID) { + signed_pre_key = this.last_resort_pre_key; + } else { + signed_pre_key = this.store.loadSignedPreKey(id); + } + deferred.resolve(signed_pre_key.keyPair || null); + }, this)); + return deferred.promise; + }; + + Encryption.prototype.getSignedPreKey = function(id) { + if (!id) { + id = this.next_signed_pre_key_id; + this.next_signed_pre_key_id++; + if (this.next_signed_pre_key_id === LAST_RESORT_PRE_KEY_ID) { + this.next_signed_pre_key_id = 1; + } + this.store.saveNextSignedPreKeyId(this.next_signed_pre_key_id); + } + + var deferred = $q.defer(); + _.defer(_.bind(function() { + _.defer(_.bind(function() { + this.axol.generateSignedPreKey(this.identity_keypair, id) + .then(_.bind(function(signed_pre_key) { + // Need to remember so it can be looked up later. + this.store.saveSignedPreKey(signed_pre_key); + deferred.resolve(signed_pre_key); + }, this)); + }, this)); + }, this)); + return deferred.promise; + }; + + Encryption.prototype.apiSend = function(type, message) { + var msg = { + "Type": type + }; + msg[type] = message; + this.api.send(type, message); + }; + + Encryption.prototype.register = function() { + this.getReadyPromise().then(_.bind(function() { + var message = { + "RegistrationId": this.registration_id, + "Identity": serializePublicKey(this.identity_keypair), + "LastResortSignedPreKey": { + "Id": this.last_resort_pre_key.id, + "Key": serializePublicKey( + this.last_resort_pre_key.keyPair), + "Signature": serializeSignature( + this.last_resort_pre_key.signature) + } + }; + this.apiSend("EncryptionRegister", message); + }, this)); + }; + + Encryption.prototype.processPendingMessages = function(peer) { + var pending = this.pending_messages[peer]; + if (pending && pending.length > 0) { + var msg = pending.shift(); + switch (msg.type) { + case "encrypt": + this.encryptWithSession( + peer, + this.sessions[peer], + msg.message, + msg.callback); + break; + case "decrypt": + this.decryptWithSession( + peer, + this.sessions[peer], + msg.message, + msg.callback); + break; + } + } else { + delete this.is_processing_messages[peer]; + delete this.pending_messages[peer]; + } + }; + + Encryption.prototype.requestKeyBundle = function(peer) { + this.getSignedPreKey().then(_.bind(function(signed_pre_key) { + var bundle = { + "To": peer, + "Identity": serializePublicKey(this.identity_keypair), + "PreKeyId": null, + "PreKey": null, + "SignedPreKeyId": signed_pre_key.id, + "SignedPreKey": + serializePublicKey(signed_pre_key.keyPair), + "SignedPreKeySignature": + serializeSignature(signed_pre_key.signature) + }; + this.apiSend("EncryptionKeyBundle", bundle); + }, this)); + }; + + Encryption.prototype.receivedKeyBundle = function(peer, data) { + var bundle = { + "identityKey": deserializePublicKey(data.Identity), + "preKeyId": null, + "preKey": null, + "signedPreKeyId": null, + "signedPreKey": null, + "signedPreKeySignature": null + }; + if (data.PreKey) { + bundle.preKeyId = data.PreKeyId; + bundle.preKey = deserializePublicKey(data.PreKey); + } + if (data.SignedPreKey) { + bundle.signedPreKeyId = data.SignedPreKeyId; + bundle.signedPreKey = deserializePublicKey(data.SignedPreKey); + bundle.signedPreKeySignature = + deserializeSignature(data.SignedPreKeySignature); + } + this.storePeerIdentity(peer, bundle.identityKey); + // TODO(fancycode): Add error handling. + this.axol.createSessionFromPreKeyBundle(bundle) + .then(_.bind(function(session) { + this.sessions[peer] = session; + this.processPendingMessages(peer); + }, this)); + }; + + Encryption.prototype.encrypt = function(peer, type, message, callback) { + var msg = {"Type": type}; + msg[type] = message; + message = JSON.stringify(msg); + if (this.is_processing_messages[peer]) { + this.pending_messages[peer].push({ + "type": "encrypt", + "message": message, + "callback": callback + }); + return; + } + + this.is_processing_messages[peer] = true; + this.pending_messages[peer] = []; + var session = this.sessions[peer]; + if (!session) { + this.pending_messages[peer].push({ + "type": "encrypt", + "message": message, + "callback": callback + }); + this.apiSend("EncryptionRequestKeyBundle", {"To": peer}); + return; + } + this.encryptWithSession(peer, session, message, callback); + }; + + Encryption.prototype.encryptWithSession = function( + peer, session, message, callback) { + var buffer = ByteBuffer.fromUTF8(message).toArrayBuffer(); + // TODO(fancycode): Add error handling. + this.axol.encryptMessage(session, buffer) + .then(_.bind(function(encrypted) { + this.sessions[peer] = encrypted.session; + var msg = { + "Message": encrypted.isPreKeyWhisperMessage ? + "PreKeyWhisper" : + "Whisper", + "Data": ByteBuffer.wrap(encrypted.body).toBase64() + }; + callback("Encrypted", msg); + this.processPendingMessages(peer); + }, this)); + }; + + Encryption.prototype.decrypt = function(peer, message, callback) { + if (this.is_processing_messages[peer]) { + this.pending_messages[peer].push({ + "type": "decrypt", + "message": message, + "callback": callback + }); + return; + } + this.is_processing_messages[peer] = true; + this.pending_messages[peer] = []; + var session = this.sessions[peer] || null; + this.decryptWithSession(peer, session, message, callback); + }; + + Encryption.prototype.decryptWithSession = function( + peer, session, message, callback) { + var handle_decrypted = _.bind(function(decrypted) { + if (decrypted.identityKey) { + this.storePeerIdentity(peer, decrypted.identityKey); + } + this.sessions[peer] = decrypted.session; + var msg = ByteBuffer.wrap(decrypted.message).toUTF8(); + callback(JSON.parse(msg)); + this.processPendingMessages(peer); + }, this); + + if (typeof(message) === "string") { + message = JSON.parse(message); + } + var buffer = ByteBuffer.fromBase64(message.Data).toArrayBuffer(); + switch (message.Message) { + case "PreKeyWhisper": + // TODO(fancycode): Add error handling. + this.axol.decryptPreKeyWhisperMessage(session, buffer) + .then(handle_decrypted); + break; + case "Whisper": + // TODO(fancycode): Add error handling. + this.axol.decryptWhisperMessage(session, buffer) + .then(handle_decrypted); + break; + } + }; + + var encryption; + + // Only export limited public encryption API. + var encryptionApi = { + "initialize": function(api) { + if (encryption) { + return true; + } + encryption = new Encryption(api); + if (!encryption.isSupported()) { + encryption = null; + return false; + } + return true; + }, + "encrypt": function(peer, type, message, callback) { + if (!encryption) { + callback(type, message); + return; + } + encryption.encrypt(peer, type, message, callback); + }, + "decrypt": function(peer, message, callback) { + if (!encryption) { + callback(message); + return; + } + encryption.decrypt(peer, message, callback); + } + }; + + return encryptionApi; + + }]; + +}); diff --git a/static/js/services/services.js b/static/js/services/services.js index 4fa9919a..25ee4a8d 100644 --- a/static/js/services/services.js +++ b/static/js/services/services.js @@ -70,7 +70,8 @@ define([ 'services/mediadevices', 'services/sandbox', 'services/dummystream', - 'services/usermedia'], function(_, + 'services/usermedia', + 'services/encryption'], function(_, desktopNotify, playSound, safeApply, @@ -118,7 +119,8 @@ modules, mediaDevices, sandbox, dummyStream, -userMedia) { +userMedia, +encryption) { var services = { desktopNotify: desktopNotify, @@ -168,7 +170,8 @@ userMedia) { mediaDevices: mediaDevices, sandbox: sandbox, dummyStream: dummyStream, - userMedia: userMedia + userMedia: userMedia, + encryption: encryption }; var initialize = function(angModule) {