diff --git a/Gruntfile.js b/Gruntfile.js index 681e92d3b..47b97006e 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -335,71 +335,82 @@ module.exports = function(grunt) { }); }); - grunt.registerTask('unit-tests', 'Run unit tests inside Electron', function() { - var environment = grunt.option('env') || 'test'; - var done = this.async(); - var failure; - - var Application = require('spectron').Application; - var electronBinary = process.platform === 'win32' ? 'electron.cmd' : 'electron'; - var app = new Application({ - path: path.join(__dirname, 'node_modules', '.bin', electronBinary), - args: [path.join(__dirname, 'main.js')], - env: { - NODE_ENV: environment - } - }); - - function getMochaResults() { - return window.mochaResults; + function runTests(environment, cb) { + var failure; + var Application = require('spectron').Application; + var electronBinary = process.platform === 'win32' ? 'electron.cmd' : 'electron'; + var app = new Application({ + path: path.join(__dirname, 'node_modules', '.bin', electronBinary), + args: [path.join(__dirname, 'main.js')], + env: { + NODE_ENV: environment } + }); - app.start().then(function() { - return app.client.waitUntil(function() { - return app.client.execute(getMochaResults).then(function(data) { - return Boolean(data.value); - }); - }, 10000, 'Expected to find window.mochaResults set!'); - }).then(function() { - return app.client.execute(getMochaResults); - }).then(function(data) { - var results = data.value; - if (results.failures > 0) { - console.error(results.reports); - failure = function() { - grunt.fail.fatal('Found ' + results.failures + ' failing unit tests.'); - }; - return app.client.log('browser'); - } else { - grunt.log.ok(results.passes + ' tests passed.'); - } - }).then(function(logs) { - if (logs) { - console.error(); - console.error('Because tests failed, printing browser logs:'); - console.error(logs); - } - }).catch(function (error) { + function getMochaResults() { + return window.mochaResults; + } + + app.start().then(function() { + return app.client.waitUntil(function() { + return app.client.execute(getMochaResults).then(function(data) { + return Boolean(data.value); + }); + }, 10000, 'Expected to find window.mochaResults set!'); + }).then(function() { + return app.client.execute(getMochaResults); + }).then(function(data) { + var results = data.value; + if (results.failures > 0) { + console.error(results.reports); failure = function() { - grunt.fail.fatal('Something went wrong: ' + error.message + ' ' + error.stack); + grunt.fail.fatal('Found ' + results.failures + ' failing unit tests.'); }; - }).then(function () { - // We need to use the failure variable and this early stop to clean up before - // shutting down. Grunt's fail methods are the only way to set the return value, - // but they shut the process down immediately! - return app.stop(); - }).then(function() { - if (failure) { - failure(); - } - done(); - }).catch(function (error) { - console.error('Second-level error:', error.message, error.stack); - if (failure) { - failure(); - } - done(); - }); + return app.client.log('browser'); + } else { + grunt.log.ok(results.passes + ' tests passed.'); + } + }).then(function(logs) { + if (logs) { + console.error(); + console.error('Because tests failed, printing browser logs:'); + console.error(logs); + } + }).catch(function (error) { + failure = function() { + grunt.fail.fatal('Something went wrong: ' + error.message + ' ' + error.stack); + }; + }).then(function () { + // We need to use the failure variable and this early stop to clean up before + // shutting down. Grunt's fail methods are the only way to set the return value, + // but they shut the process down immediately! + return app.stop(); + }).then(function() { + if (failure) { + failure(); + } + cb(); + }).catch(function (error) { + console.error('Second-level error:', error.message, error.stack); + if (failure) { + failure(); + } + cb(); + }); + } + + grunt.registerTask('unit-tests', 'Run unit tests w/Electron', function() { + var environment = grunt.option('env') || 'test'; + var done = this.async(); + + runTests(environment, done); + }); + + grunt.registerTask('lib-unit-tests', 'Run libtextsecure unit tests w/Electron', function() { + var environment = grunt.option('env') || 'test-lib'; + var done = this.async(); + + runTests(environment, done); }); grunt.registerMultiTask('test-release', 'Test packaged releases', function() { @@ -473,7 +484,7 @@ module.exports = function(grunt) { grunt.registerTask('tx', ['exec:tx-pull', 'locale-patch']); grunt.registerTask('dev', ['default', 'watch']); - grunt.registerTask('test', ['jshint', 'jscs', 'unit-tests']); + grunt.registerTask('test', ['jshint', 'jscs', 'unit-tests', 'lib-unit-tests']); grunt.registerTask('copy_dist', ['gitinfo', 'copy:res', 'copy:src']); grunt.registerTask('date', ['gitinfo', 'getExpireTime']); grunt.registerTask('prep-release', ['gitinfo', 'clean-release', 'fetch-release']); diff --git a/app/logging.js b/app/logging.js index a7966d257..1257c24d0 100644 --- a/app/logging.js +++ b/app/logging.js @@ -93,8 +93,19 @@ function fetch(logPath) { return path.join(logPath, file) }); + // creating a manual log entry for the final log result + var now = new Date(); + const fileListEntry = { + level: 30, // INFO + time: now.toJSON(), + msg: 'Loaded this list of log files from logPath: ' + files.join(', '), + }; + return Promise.all(paths.map(fetchLog)).then(function(results) { const data = _.flatten(results); + + data.push(fileListEntry); + return _.sortBy(data, 'time'); }); } diff --git a/config/test-lib.json b/config/test-lib.json new file mode 100644 index 000000000..d0e5c25d8 --- /dev/null +++ b/config/test-lib.json @@ -0,0 +1,5 @@ +{ + "storageProfile": "test", + "disableAutoUpdate": true, + "openDevTools": false +} diff --git a/js/background.js b/js/background.js index e3cde97ea..238394f34 100644 --- a/js/background.js +++ b/js/background.js @@ -88,9 +88,10 @@ function start() { var currentVersion = window.config.version; var lastVersion = storage.get('version'); + var newVersion = !lastVersion || currentVersion !== lastVersion; storage.put('version', currentVersion); - if (!lastVersion || currentVersion !== lastVersion) { + if (newVersion) { console.log('New version detected:', currentVersion); } @@ -99,7 +100,7 @@ console.log('listening for registration events'); Whisper.events.on('registration_done', function() { console.log('handling registration event'); - Whisper.RotateSignedPreKeyListener.init(Whisper.events); + Whisper.RotateSignedPreKeyListener.init(Whisper.events, newVersion); connect(true); }); @@ -112,7 +113,7 @@ console.log('Import was interrupted, showing import error screen'); appView.openImporter(); } else if (Whisper.Registration.everDone()) { - Whisper.RotateSignedPreKeyListener.init(Whisper.events); + Whisper.RotateSignedPreKeyListener.init(Whisper.events, newVersion); connect(); appView.openInbox({ initialLoadComplete: initialLoadComplete @@ -377,6 +378,14 @@ return ConversationController.getOrCreateAndWait(id, 'private') .then(function(conversation) { return new Promise(function(resolve, reject) { + var activeAt = conversation.get('active_at'); + + // The idea is to make any new contact show up in the left pane. If + // activeAt is null, then this contact has been purposefully hidden. + if (activeAt !== null) { + activeAt = activeAt || Date.now(); + } + if (details.profileKey) { conversation.set({profileKey: details.profileKey}); } @@ -384,7 +393,7 @@ name: details.name, avatar: details.avatar, color: details.color, - active_at: conversation.get('active_at') || Date.now(), + active_at: activeAt, }).then(resolve, reject); }).then(function() { if (details.verified) { @@ -421,7 +430,13 @@ type: 'group', }; if (details.active) { - updates.active_at = Date.now(); + var activeAt = conversation.get('active_at'); + + // The idea is to make any new group show up in the left pane. If + // activeAt is null, then this group has been purposefully hidden. + if (activeAt !== null) { + updates.active_at = activeAt || Date.now(); + } } else { updates.left = true; } @@ -553,8 +568,7 @@ function onError(ev) { var error = ev.error; - console.log(error); - console.log(error.stack); + console.log('background onError:', error && error.stack ? error.stack : error); if (error.name === 'HTTPError' && (error.code == 401 || error.code == 403)) { Whisper.Registration.remove(); diff --git a/js/libtextsecure.js b/js/libtextsecure.js index 576c291fa..f8cd18f21 100644 --- a/js/libtextsecure.js +++ b/js/libtextsecure.js @@ -38003,26 +38003,35 @@ var TextSecureServer = (function() { rotateSignedPreKey: function() { return this.queueTask(function() { var signedKeyId = textsecure.storage.get('signedKeyId', 1); - if (typeof signedKeyId != 'number') { throw new Error('Invalid signedKeyId'); } + var store = textsecure.storage.protocol; var server = this.server; var cleanSignedPreKeys = this.cleanSignedPreKeys; + return store.getIdentityKeyPair().then(function(identityKey) { return libsignal.KeyHelper.generateSignedPreKey(identityKey, signedKeyId); }).then(function(res) { - return server.setSignedPreKey({ - keyId : res.keyId, - publicKey : res.keyPair.pubKey, - signature : res.signature + console.log('Saving new signed prekey', res.keyId); + return Promise.all([ + textsecure.storage.put('signedKeyId', signedKeyId + 1), + store.storeSignedPreKey(res.keyId, res.keyPair), + server.setSignedPreKey({ + keyId : res.keyId, + publicKey : res.keyPair.pubKey, + signature : res.signature + }), + ]).then(function() { + var confirmed = true; + console.log('Confirming new signed prekey', res.keyId); + return Promise.all([ + textsecure.storage.remove('signedKeyRotationRejected'), + store.storeSignedPreKey(res.keyId, res.keyPair, confirmed), + ]); }).then(function() { - textsecure.storage.put('signedKeyId', signedKeyId + 1); - textsecure.storage.remove('signedKeyRotationRejected'); - return store.storeSignedPreKey(res.keyId, res.keyPair).then(function() { - return cleanSignedPreKeys(); - }); + return cleanSignedPreKeys(); }); }).catch(function(e) { console.log( @@ -38034,9 +38043,9 @@ var TextSecureServer = (function() { var rejections = 1 + textsecure.storage.get('signedKeyRotationRejected', 0); textsecure.storage.put('signedKeyRotationRejected', rejections); console.log('Signed key rotation rejected count:', rejections); + } else { + throw e; } - - throw e; }); }.bind(this)); }, @@ -38045,35 +38054,72 @@ var TextSecureServer = (function() { return this.pending = this.pending.then(taskWithTimeout, taskWithTimeout); }, cleanSignedPreKeys: function() { - var nextSignedKeyId = textsecure.storage.get('signedKeyId'); - if (typeof nextSignedKeyId != 'number') { - return Promise.resolve(); - } - var activeSignedPreKeyId = nextSignedKeyId - 1; - + var MINIMUM_KEYS = 3; var store = textsecure.storage.protocol; - return store.loadSignedPreKeys().then(function(allRecords) { - var oldRecords = allRecords.filter(function(record) { - return record.keyId !== activeSignedPreKeyId; - }); - oldRecords.sort(function(a, b) { + return store.loadSignedPreKeys().then(function(allKeys) { + allKeys.sort(function(a, b) { return (a.created_at || 0) - (b.created_at || 0); }); + allKeys.reverse(); // we want the most recent first + var confirmed = allKeys.filter(function(key) { + return key.confirmed; + }); + var unconfirmed = allKeys.filter(function(key) { + return !key.confirmed; + }); - console.log("Active signed prekey: " + activeSignedPreKeyId); - console.log("Old signed prekey record count: " + oldRecords.length); + var recent = allKeys[0] ? allKeys[0].keyId : 'none'; + var recentConfirmed = confirmed[0] ? confirmed[0].keyId : 'none'; + console.log('Most recent signed key: ' + recent); + console.log('Most recent confirmed signed key: ' + recentConfirmed); + console.log( + 'Total signed key count:', + allKeys.length, + '-', + confirmed.length, + 'confirmed' + ); - oldRecords.forEach(function(oldRecord) { - if ( oldRecord.keyId > activeSignedPreKeyId - 3 ) { - // keep at least the last 3 signed keys + var confirmedCount = confirmed.length; + + // Keep MINIMUM_KEYS confirmed keys, then drop if older than a week + confirmed = confirmed.forEach(function(key, index) { + if (index < MINIMUM_KEYS) { return; } - var created_at = oldRecord.created_at || 0; - var archiveDuration = Date.now() - created_at; - if (archiveDuration > ARCHIVE_AGE) { - console.log("Removing signed prekey record:", - oldRecord.keyId, "with timestamp:", created_at); - store.removeSignedPreKey(oldRecord.keyId); + var created_at = key.created_at || 0; + var age = Date.now() - created_at; + if (age > ARCHIVE_AGE) { + console.log( + 'Removing confirmed signed prekey:', + key.keyId, + 'with timestamp:', + created_at + ); + store.removeSignedPreKey(key.keyId); + confirmedCount--; + } + }); + + var stillNeeded = MINIMUM_KEYS - confirmedCount; + + // If we still don't have enough total keys, we keep as many unconfirmed + // keys as necessary. If not necessary, and over a week old, we drop. + unconfirmed.forEach(function(key, index) { + if (index < stillNeeded) { + return; + } + + var created_at = key.created_at || 0; + var age = Date.now() - created_at; + if (age > ARCHIVE_AGE) { + console.log( + 'Removing unconfirmed signed prekey:', + key.keyId, + 'with timestamp:', + created_at + ); + store.removeSignedPreKey(key.keyId); } }); }); @@ -39054,8 +39100,7 @@ MessageReceiver.prototype.extend({ console.log('Got SyncMessage Request'); return this.removeFromCache(envelope); } else if (syncMessage.read && syncMessage.read.length) { - console.log('read messages', - 'from', envelope.source + '.' + envelope.sourceDevice); + console.log('read messages from', this.getEnvelopeId(envelope)); return this.handleRead(envelope, syncMessage.read); } else if (syncMessage.verified) { return this.handleVerified(envelope, syncMessage.verified); @@ -39166,6 +39211,7 @@ MessageReceiver.prototype.extend({ }.bind(this)); }, handleBlocked: function(envelope, blocked) { + console.log('Setting these numbers as blocked:', blocked.numbers); textsecure.storage.put('blocked', blocked.numbers); }, isBlocked: function(number) { @@ -39908,10 +39954,11 @@ MessageSender.prototype = { var proto = textsecure.protobuf.DataMessage.decode(encodedMessage); return new Promise(function(resolve, reject) { this.sendMessageProto(timestamp, numbers, proto, function(res) { - if (res.errors.length > 0) + if (res.errors.length > 0) { reject(res); - else + } else { resolve(res); + } }); }.bind(this)); }, @@ -39963,7 +40010,9 @@ MessageSender.prototype = { syncMessage.sent = sentMessage; var contentMessage = new textsecure.protobuf.Content(); contentMessage.syncMessage = syncMessage; - return this.sendIndividualProto(myNumber, contentMessage, Date.now()); + + var silent = true; + return this.sendIndividualProto(myNumber, contentMessage, Date.now(), silent); }, getProfile: function(number) { @@ -39984,7 +40033,8 @@ MessageSender.prototype = { var contentMessage = new textsecure.protobuf.Content(); contentMessage.syncMessage = syncMessage; - return this.sendIndividualProto(myNumber, contentMessage, Date.now()); + var silent = true; + return this.sendIndividualProto(myNumber, contentMessage, Date.now(), silent); } return Promise.resolve(); @@ -40000,7 +40050,8 @@ MessageSender.prototype = { var contentMessage = new textsecure.protobuf.Content(); contentMessage.syncMessage = syncMessage; - return this.sendIndividualProto(myNumber, contentMessage, Date.now()); + var silent = true; + return this.sendIndividualProto(myNumber, contentMessage, Date.now(), silent); } return Promise.resolve(); @@ -40017,7 +40068,8 @@ MessageSender.prototype = { var contentMessage = new textsecure.protobuf.Content(); contentMessage.syncMessage = syncMessage; - return this.sendIndividualProto(myNumber, contentMessage, Date.now()); + var silent = true; + return this.sendIndividualProto(myNumber, contentMessage, Date.now(), silent); } return Promise.resolve(); @@ -40030,7 +40082,8 @@ MessageSender.prototype = { var contentMessage = new textsecure.protobuf.Content(); contentMessage.receiptMessage = receiptMessage; - return this.sendIndividualProto(sender, contentMessage, Date.now(), true /*silent*/); + var silent = true; + return this.sendIndividualProto(sender, contentMessage, Date.now(), silent); }, syncReadMessages: function(reads) { var myNumber = textsecure.storage.user.getNumber(); @@ -40047,7 +40100,8 @@ MessageSender.prototype = { var contentMessage = new textsecure.protobuf.Content(); contentMessage.syncMessage = syncMessage; - return this.sendIndividualProto(myNumber, contentMessage, Date.now()); + var silent = true; + return this.sendIndividualProto(myNumber, contentMessage, Date.now(), silent); } return Promise.resolve(); @@ -40055,38 +40109,44 @@ MessageSender.prototype = { syncVerification: function(destination, state, identityKey) { var myNumber = textsecure.storage.user.getNumber(); var myDevice = textsecure.storage.user.getDeviceId(); - if (myDevice != 1) { - // First send a null message to mask the sync message. - var nullMessage = new textsecure.protobuf.NullMessage(); + var now = Date.now(); - // Generate a random int from 1 and 512 + if (myDevice === 1) { + return Promise.resolve(); + } + + // First send a null message to mask the sync message. + var nullMessage = new textsecure.protobuf.NullMessage(); + + // Generate a random int from 1 and 512 var buffer = libsignal.crypto.getRandomBytes(1); - var paddingLength = (new Uint8Array(buffer)[0] & 0x1ff) + 1; + var paddingLength = (new Uint8Array(buffer)[0] & 0x1ff) + 1; - // Generate a random padding buffer of the chosen size - nullMessage.padding = libsignal.crypto.getRandomBytes(paddingLength); + // Generate a random padding buffer of the chosen size + nullMessage.padding = libsignal.crypto.getRandomBytes(paddingLength); - var contentMessage = new textsecure.protobuf.Content(); - contentMessage.nullMessage = nullMessage; + var contentMessage = new textsecure.protobuf.Content(); + contentMessage.nullMessage = nullMessage; - return this.sendIndividualProto(destination, contentMessage, Date.now()).then(function() { - var verified = new textsecure.protobuf.Verified(); - verified.state = state; - verified.destination = destination; - verified.identityKey = identityKey; - verified.nullMessage = nullMessage.padding; + // We want the NullMessage to look like a normal outgoing message; not silent + const promise = this.sendIndividualProto(destination, contentMessage, now); - var syncMessage = this.createSyncMessage(); - syncMessage.verified = verified; + return promise.then(function() { + var verified = new textsecure.protobuf.Verified(); + verified.state = state; + verified.destination = destination; + verified.identityKey = identityKey; + verified.nullMessage = nullMessage.padding; - var contentMessage = new textsecure.protobuf.Content(); - contentMessage.syncMessage = syncMessage; + var syncMessage = this.createSyncMessage(); + syncMessage.verified = verified; - return this.sendIndividualProto(myNumber, contentMessage, Date.now()); - }.bind(this)); - } + var contentMessage = new textsecure.protobuf.Content(); + contentMessage.syncMessage = syncMessage; - return Promise.resolve(); + var silent = true; + return this.sendIndividualProto(myNumber, contentMessage, now, silent); + }.bind(this)); }, sendGroupProto: function(numbers, proto, timestamp) { @@ -40098,14 +40158,17 @@ MessageSender.prototype = { } return new Promise(function(resolve, reject) { - this.sendMessageProto(timestamp, numbers, proto, function(res) { + var silent = true; + var callback = function(res) { res.dataMessage = proto.toArrayBuffer(); if (res.errors.length > 0) { reject(res); } else { resolve(res); } - }.bind(this)); + }.bind(this); + + this.sendMessageProto(timestamp, numbers, proto, callback, silent); }.bind(this)); }, diff --git a/js/logging.js b/js/logging.js index b2d864f5b..cd66f9e97 100644 --- a/js/logging.js +++ b/js/logging.js @@ -166,6 +166,10 @@ window.log = { }; window.onerror = function(message, script, line, col, error) { - window.log.error(error.stack); + const errorInfo = error && error.stack ? error.stack : JSON.stringify(error); + window.log.error('Top-level unhandled error: ' + errorInfo); }; +window.addEventListener('unhandledrejection', function(rejectionEvent) { + window.log.error('Top-level unhandled promise rejection: ' + rejectionEvent.reason); +}); diff --git a/js/models/messages.js b/js/models/messages.js index 76732958c..7f8d5ddfe 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -314,8 +314,11 @@ errors = [errors]; } errors.forEach(function(e) { - console.log(e); - console.log(e.reason, e.stack); + console.log( + 'Message.saveErrors:', + e && e.reason ? e.reason : null, + e && e.stack ? e.stack : e + ); }); errors = errors.map(function(e) { if (e.constructor === Error || diff --git a/js/rotate_signed_prekey_listener.js b/js/rotate_signed_prekey_listener.js index a52ece8e3..ce2d2a593 100644 --- a/js/rotate_signed_prekey_listener.js +++ b/js/rotate_signed_prekey_listener.js @@ -65,13 +65,12 @@ } initComplete = true; - if (Whisper.Registration.isDone()) { + if (newVersion) { + runWhenOnline(); + } else { setTimeoutForNextRun(); } - events.on('registration_done', function() { - scheduleNextRotation(); - setTimeoutForNextRun(); - }); + events.on('timetravel', function() { if (Whisper.Registration.isDone()) { setTimeoutForNextRun(); diff --git a/js/signal_protocol_store.js b/js/signal_protocol_store.js index e3beac5b9..f9a5403df 100644 --- a/js/signal_protocol_store.js +++ b/js/signal_protocol_store.js @@ -186,11 +186,15 @@ var prekey = new PreKey({id: keyId}); return new Promise(function(resolve) { prekey.fetch().then(function() { + console.log('Successfully fetched prekey:', keyId); resolve({ - pubKey: prekey.attributes.publicKey, - privKey: prekey.attributes.privateKey + pubKey: prekey.get('publicKey'), + privKey: prekey.get('privateKey'), }); - }).fail(resolve); + }, function() { + console.log('Failed to load prekey:', keyId); + resolve(); + }); }); }, storePreKey: function(keyId, keyPair) { @@ -211,7 +215,16 @@ this.trigger('removePreKey'); return new Promise(function(resolve) { - prekey.destroy().then(function() { + var deferred = prekey.destroy(); + if (!deferred) { + return resolve(); + } + + return deferred.then(resolve, function(error) { + console.log( + 'removePreKey error:', + error && error.stack ? error.stack : error + ); resolve(); }); }); @@ -222,21 +235,23 @@ var prekey = new SignedPreKey({id: keyId}); return new Promise(function(resolve) { prekey.fetch().then(function() { + console.log('Successfully loaded prekey:', prekey.get('id')); resolve({ pubKey : prekey.get('publicKey'), privKey : prekey.get('privateKey'), created_at : prekey.get('created_at'), - keyId : prekey.get('id') + keyId : prekey.get('id'), + confirmed : prekey.get('confirmed'), }); }).fail(function() { - console.log("Failed to load signed prekey:", keyId); + console.log('Failed to load signed prekey:', keyId); resolve(); }); }); }, loadSignedPreKeys: function() { if (arguments.length > 0) { - return Promise.reject(new Error("loadSignedPreKeys takes no arguments")); + return Promise.reject(new Error('loadSignedPreKeys takes no arguments')); } var signedPreKeys = new SignedPreKeyCollection(); return new Promise(function(resolve) { @@ -246,18 +261,20 @@ pubKey : prekey.get('publicKey'), privKey : prekey.get('privateKey'), created_at : prekey.get('created_at'), - keyId : prekey.get('id') + keyId : prekey.get('id'), + confirmed : prekey.get('confirmed'), }; })); }); }); }, - storeSignedPreKey: function(keyId, keyPair) { + storeSignedPreKey: function(keyId, keyPair, confirmed) { var prekey = new SignedPreKey({ id : keyId, publicKey : keyPair.pubKey, privateKey : keyPair.privKey, - created_at : Date.now() + created_at : Date.now(), + confirmed : Boolean(confirmed), }); return new Promise(function(resolve) { prekey.save().always(function() { diff --git a/libtextsecure/account_manager.js b/libtextsecure/account_manager.js index 897d58407..69f7c2c8a 100644 --- a/libtextsecure/account_manager.js +++ b/libtextsecure/account_manager.js @@ -118,26 +118,35 @@ rotateSignedPreKey: function() { return this.queueTask(function() { var signedKeyId = textsecure.storage.get('signedKeyId', 1); - if (typeof signedKeyId != 'number') { throw new Error('Invalid signedKeyId'); } + var store = textsecure.storage.protocol; var server = this.server; var cleanSignedPreKeys = this.cleanSignedPreKeys; + return store.getIdentityKeyPair().then(function(identityKey) { return libsignal.KeyHelper.generateSignedPreKey(identityKey, signedKeyId); }).then(function(res) { - return server.setSignedPreKey({ - keyId : res.keyId, - publicKey : res.keyPair.pubKey, - signature : res.signature + console.log('Saving new signed prekey', res.keyId); + return Promise.all([ + textsecure.storage.put('signedKeyId', signedKeyId + 1), + store.storeSignedPreKey(res.keyId, res.keyPair), + server.setSignedPreKey({ + keyId : res.keyId, + publicKey : res.keyPair.pubKey, + signature : res.signature + }), + ]).then(function() { + var confirmed = true; + console.log('Confirming new signed prekey', res.keyId); + return Promise.all([ + textsecure.storage.remove('signedKeyRotationRejected'), + store.storeSignedPreKey(res.keyId, res.keyPair, confirmed), + ]); }).then(function() { - textsecure.storage.put('signedKeyId', signedKeyId + 1); - textsecure.storage.remove('signedKeyRotationRejected'); - return store.storeSignedPreKey(res.keyId, res.keyPair).then(function() { - return cleanSignedPreKeys(); - }); + return cleanSignedPreKeys(); }); }).catch(function(e) { console.log( @@ -149,9 +158,9 @@ var rejections = 1 + textsecure.storage.get('signedKeyRotationRejected', 0); textsecure.storage.put('signedKeyRotationRejected', rejections); console.log('Signed key rotation rejected count:', rejections); + } else { + throw e; } - - throw e; }); }.bind(this)); }, @@ -160,35 +169,72 @@ return this.pending = this.pending.then(taskWithTimeout, taskWithTimeout); }, cleanSignedPreKeys: function() { - var nextSignedKeyId = textsecure.storage.get('signedKeyId'); - if (typeof nextSignedKeyId != 'number') { - return Promise.resolve(); - } - var activeSignedPreKeyId = nextSignedKeyId - 1; - + var MINIMUM_KEYS = 3; var store = textsecure.storage.protocol; - return store.loadSignedPreKeys().then(function(allRecords) { - var oldRecords = allRecords.filter(function(record) { - return record.keyId !== activeSignedPreKeyId; - }); - oldRecords.sort(function(a, b) { + return store.loadSignedPreKeys().then(function(allKeys) { + allKeys.sort(function(a, b) { return (a.created_at || 0) - (b.created_at || 0); }); + allKeys.reverse(); // we want the most recent first + var confirmed = allKeys.filter(function(key) { + return key.confirmed; + }); + var unconfirmed = allKeys.filter(function(key) { + return !key.confirmed; + }); + + var recent = allKeys[0] ? allKeys[0].keyId : 'none'; + var recentConfirmed = confirmed[0] ? confirmed[0].keyId : 'none'; + console.log('Most recent signed key: ' + recent); + console.log('Most recent confirmed signed key: ' + recentConfirmed); + console.log( + 'Total signed key count:', + allKeys.length, + '-', + confirmed.length, + 'confirmed' + ); - console.log("Active signed prekey: " + activeSignedPreKeyId); - console.log("Old signed prekey record count: " + oldRecords.length); + var confirmedCount = confirmed.length; - oldRecords.forEach(function(oldRecord) { - if ( oldRecord.keyId > activeSignedPreKeyId - 3 ) { - // keep at least the last 3 signed keys + // Keep MINIMUM_KEYS confirmed keys, then drop if older than a week + confirmed = confirmed.forEach(function(key, index) { + if (index < MINIMUM_KEYS) { return; } - var created_at = oldRecord.created_at || 0; - var archiveDuration = Date.now() - created_at; - if (archiveDuration > ARCHIVE_AGE) { - console.log("Removing signed prekey record:", - oldRecord.keyId, "with timestamp:", created_at); - store.removeSignedPreKey(oldRecord.keyId); + var created_at = key.created_at || 0; + var age = Date.now() - created_at; + if (age > ARCHIVE_AGE) { + console.log( + 'Removing confirmed signed prekey:', + key.keyId, + 'with timestamp:', + created_at + ); + store.removeSignedPreKey(key.keyId); + confirmedCount--; + } + }); + + var stillNeeded = MINIMUM_KEYS - confirmedCount; + + // If we still don't have enough total keys, we keep as many unconfirmed + // keys as necessary. If not necessary, and over a week old, we drop. + unconfirmed.forEach(function(key, index) { + if (index < stillNeeded) { + return; + } + + var created_at = key.created_at || 0; + var age = Date.now() - created_at; + if (age > ARCHIVE_AGE) { + console.log( + 'Removing unconfirmed signed prekey:', + key.keyId, + 'with timestamp:', + created_at + ); + store.removeSignedPreKey(key.keyId); } }); }); diff --git a/libtextsecure/message_receiver.js b/libtextsecure/message_receiver.js index 3d60f11c8..11434168a 100644 --- a/libtextsecure/message_receiver.js +++ b/libtextsecure/message_receiver.js @@ -618,8 +618,7 @@ MessageReceiver.prototype.extend({ console.log('Got SyncMessage Request'); return this.removeFromCache(envelope); } else if (syncMessage.read && syncMessage.read.length) { - console.log('read messages', - 'from', envelope.source + '.' + envelope.sourceDevice); + console.log('read messages from', this.getEnvelopeId(envelope)); return this.handleRead(envelope, syncMessage.read); } else if (syncMessage.verified) { return this.handleVerified(envelope, syncMessage.verified); @@ -730,6 +729,7 @@ MessageReceiver.prototype.extend({ }.bind(this)); }, handleBlocked: function(envelope, blocked) { + console.log('Setting these numbers as blocked:', blocked.numbers); textsecure.storage.put('blocked', blocked.numbers); }, isBlocked: function(number) { diff --git a/libtextsecure/sendmessage.js b/libtextsecure/sendmessage.js index ba55f97bb..216441f53 100644 --- a/libtextsecure/sendmessage.js +++ b/libtextsecure/sendmessage.js @@ -265,10 +265,11 @@ MessageSender.prototype = { var proto = textsecure.protobuf.DataMessage.decode(encodedMessage); return new Promise(function(resolve, reject) { this.sendMessageProto(timestamp, numbers, proto, function(res) { - if (res.errors.length > 0) + if (res.errors.length > 0) { reject(res); - else + } else { resolve(res); + } }); }.bind(this)); }, @@ -320,7 +321,9 @@ MessageSender.prototype = { syncMessage.sent = sentMessage; var contentMessage = new textsecure.protobuf.Content(); contentMessage.syncMessage = syncMessage; - return this.sendIndividualProto(myNumber, contentMessage, Date.now()); + + var silent = true; + return this.sendIndividualProto(myNumber, contentMessage, Date.now(), silent); }, getProfile: function(number) { @@ -341,7 +344,8 @@ MessageSender.prototype = { var contentMessage = new textsecure.protobuf.Content(); contentMessage.syncMessage = syncMessage; - return this.sendIndividualProto(myNumber, contentMessage, Date.now()); + var silent = true; + return this.sendIndividualProto(myNumber, contentMessage, Date.now(), silent); } return Promise.resolve(); @@ -357,7 +361,8 @@ MessageSender.prototype = { var contentMessage = new textsecure.protobuf.Content(); contentMessage.syncMessage = syncMessage; - return this.sendIndividualProto(myNumber, contentMessage, Date.now()); + var silent = true; + return this.sendIndividualProto(myNumber, contentMessage, Date.now(), silent); } return Promise.resolve(); @@ -374,7 +379,8 @@ MessageSender.prototype = { var contentMessage = new textsecure.protobuf.Content(); contentMessage.syncMessage = syncMessage; - return this.sendIndividualProto(myNumber, contentMessage, Date.now()); + var silent = true; + return this.sendIndividualProto(myNumber, contentMessage, Date.now(), silent); } return Promise.resolve(); @@ -387,7 +393,8 @@ MessageSender.prototype = { var contentMessage = new textsecure.protobuf.Content(); contentMessage.receiptMessage = receiptMessage; - return this.sendIndividualProto(sender, contentMessage, Date.now(), true /*silent*/); + var silent = true; + return this.sendIndividualProto(sender, contentMessage, Date.now(), silent); }, syncReadMessages: function(reads) { var myNumber = textsecure.storage.user.getNumber(); @@ -404,7 +411,8 @@ MessageSender.prototype = { var contentMessage = new textsecure.protobuf.Content(); contentMessage.syncMessage = syncMessage; - return this.sendIndividualProto(myNumber, contentMessage, Date.now()); + var silent = true; + return this.sendIndividualProto(myNumber, contentMessage, Date.now(), silent); } return Promise.resolve(); @@ -412,38 +420,44 @@ MessageSender.prototype = { syncVerification: function(destination, state, identityKey) { var myNumber = textsecure.storage.user.getNumber(); var myDevice = textsecure.storage.user.getDeviceId(); - if (myDevice != 1) { - // First send a null message to mask the sync message. - var nullMessage = new textsecure.protobuf.NullMessage(); + var now = Date.now(); - // Generate a random int from 1 and 512 + if (myDevice === 1) { + return Promise.resolve(); + } + + // First send a null message to mask the sync message. + var nullMessage = new textsecure.protobuf.NullMessage(); + + // Generate a random int from 1 and 512 var buffer = libsignal.crypto.getRandomBytes(1); - var paddingLength = (new Uint8Array(buffer)[0] & 0x1ff) + 1; + var paddingLength = (new Uint8Array(buffer)[0] & 0x1ff) + 1; - // Generate a random padding buffer of the chosen size - nullMessage.padding = libsignal.crypto.getRandomBytes(paddingLength); + // Generate a random padding buffer of the chosen size + nullMessage.padding = libsignal.crypto.getRandomBytes(paddingLength); - var contentMessage = new textsecure.protobuf.Content(); - contentMessage.nullMessage = nullMessage; + var contentMessage = new textsecure.protobuf.Content(); + contentMessage.nullMessage = nullMessage; - return this.sendIndividualProto(destination, contentMessage, Date.now()).then(function() { - var verified = new textsecure.protobuf.Verified(); - verified.state = state; - verified.destination = destination; - verified.identityKey = identityKey; - verified.nullMessage = nullMessage.padding; + // We want the NullMessage to look like a normal outgoing message; not silent + const promise = this.sendIndividualProto(destination, contentMessage, now); - var syncMessage = this.createSyncMessage(); - syncMessage.verified = verified; + return promise.then(function() { + var verified = new textsecure.protobuf.Verified(); + verified.state = state; + verified.destination = destination; + verified.identityKey = identityKey; + verified.nullMessage = nullMessage.padding; - var contentMessage = new textsecure.protobuf.Content(); - contentMessage.syncMessage = syncMessage; + var syncMessage = this.createSyncMessage(); + syncMessage.verified = verified; - return this.sendIndividualProto(myNumber, contentMessage, Date.now()); - }.bind(this)); - } + var contentMessage = new textsecure.protobuf.Content(); + contentMessage.syncMessage = syncMessage; - return Promise.resolve(); + var silent = true; + return this.sendIndividualProto(myNumber, contentMessage, now, silent); + }.bind(this)); }, sendGroupProto: function(numbers, proto, timestamp) { @@ -455,14 +469,17 @@ MessageSender.prototype = { } return new Promise(function(resolve, reject) { - this.sendMessageProto(timestamp, numbers, proto, function(res) { + var silent = true; + var callback = function(res) { res.dataMessage = proto.toArrayBuffer(); if (res.errors.length > 0) { reject(res); } else { resolve(res); } - }.bind(this)); + }.bind(this); + + this.sendMessageProto(timestamp, numbers, proto, callback, silent); }.bind(this)); }, diff --git a/libtextsecure/test/_test.js b/libtextsecure/test/_test.js index d4d557b80..dfbc95ee2 100644 --- a/libtextsecure/test/_test.js +++ b/libtextsecure/test/_test.js @@ -1,5 +1,6 @@ mocha.setup("bdd"); window.assert = chai.assert; +window.PROTO_ROOT = '../../protos'; (function() { var OriginalReporter = mocha._reporter; @@ -52,3 +53,5 @@ function hexToArrayBuffer(str) { array[i] = parseInt(str.substr(i*2, 2), 16); return ret; }; + +window.MockSocket.prototype.addEventListener = function() {}; diff --git a/libtextsecure/test/account_manager_test.js b/libtextsecure/test/account_manager_test.js new file mode 100644 index 000000000..309ed52c9 --- /dev/null +++ b/libtextsecure/test/account_manager_test.js @@ -0,0 +1,156 @@ +'use strict'; + +describe("AccountManager", function() { + let accountManager; + let originalServer; + + before(function() { + originalServer = window.TextSecureServer; + window.TextSecureServer = function() {}; + }); + after(function() { + window.TextSecureServer = originalServer; + }); + + beforeEach(function() { + accountManager = new window.textsecure.AccountManager(); + }); + + describe('#cleanSignedPreKeys', function() { + let originalProtocolStorage; + let signedPreKeys; + const DAY = 1000 * 60 * 60 * 24; + + beforeEach(function() { + originalProtocolStorage = window.textsecure.storage.protocol; + window.textsecure.storage.protocol = { + loadSignedPreKeys: function() { + return Promise.resolve(signedPreKeys); + }, + }; + }); + afterEach(function() { + window.textsecure.storage.protocol = originalProtocolStorage; + }); + + it('keeps three confirmed keys even if over a week old', function() { + const now = Date.now(); + signedPreKeys = [{ + keyId: 1, + created_at: now - DAY * 21, + confirmed: true, + }, { + keyId: 2, + created_at: now - DAY * 14, + confirmed: true, + }, { + keyId: 3, + created_at: now - DAY * 18, + confirmed: true, + }]; + + // should be no calls to store.removeSignedPreKey, would cause crash + return accountManager.cleanSignedPreKeys(); + }); + + it('eliminates confirmed keys over a week old, if more than three', function() { + const now = Date.now(); + signedPreKeys = [{ + keyId: 1, + created_at: now - DAY * 21, + confirmed: true, + }, { + keyId: 2, + created_at: now - DAY * 14, + confirmed: true, + }, { + keyId: 3, + created_at: now - DAY * 4, + confirmed: true, + }, { + keyId: 4, + created_at: now - DAY * 18, + confirmed: true, + }, { + keyId: 5, + created_at: now - DAY, + confirmed: true, + }]; + + let count = 0; + window.textsecure.storage.protocol.removeSignedPreKey = function(keyId) { + if (keyId !== 1 && keyId !== 4) { + throw new Error('Wrong keys were eliminated! ' + keyId); + } + + count++; + }; + + return accountManager.cleanSignedPreKeys().then(function() { + assert.strictEqual(count, 2); + }); + }); + + it('keeps at least three unconfirmed keys if no confirmed', function() { + const now = Date.now(); + signedPreKeys = [{ + keyId: 1, + created_at: now - DAY * 14, + }, { + keyId: 2, + created_at: now - DAY * 21, + }, { + keyId: 3, + created_at: now - DAY * 18, + }, { + keyId: 4, + created_at: now - DAY + }]; + + let count = 0; + window.textsecure.storage.protocol.removeSignedPreKey = function(keyId) { + if (keyId !== 2) { + throw new Error('Wrong keys were eliminated! ' + keyId); + } + + count++; + }; + + return accountManager.cleanSignedPreKeys().then(function() { + assert.strictEqual(count, 1); + }); + }); + + it('if some confirmed keys, keeps unconfirmed to addd up to three total', function() { + const now = Date.now(); + signedPreKeys = [{ + keyId: 1, + created_at: now - DAY * 21, + confirmed: true, + }, { + keyId: 2, + created_at: now - DAY * 14, + confirmed: true, + }, { + keyId: 3, + created_at: now - DAY * 12, + }, { + keyId: 4, + created_at: now - DAY * 8, + }]; + + let count = 0; + window.textsecure.storage.protocol.removeSignedPreKey = function(keyId) { + if (keyId !== 3) { + throw new Error('Wrong keys were eliminated! ' + keyId); + } + + count++; + }; + + return accountManager.cleanSignedPreKeys().then(function() { + assert.strictEqual(count, 1); + }); + }); + }); +}); diff --git a/libtextsecure/test/index.html b/libtextsecure/test/index.html index 5c8cd7b88..4064ed9e0 100644 --- a/libtextsecure/test/index.html +++ b/libtextsecure/test/index.html @@ -1,6 +1,7 @@ + libTextSecure test runner @@ -12,7 +13,6 @@ - @@ -23,12 +23,12 @@ + - @@ -42,5 +42,14 @@ + + + + + + + diff --git a/libtextsecure/test/protos b/libtextsecure/test/protos deleted file mode 120000 index 3d021e597..000000000 --- a/libtextsecure/test/protos +++ /dev/null @@ -1 +0,0 @@ -../../protos/ \ No newline at end of file diff --git a/libtextsecure/test/test.js b/libtextsecure/test/test.js index bb1cc7a0d..660b25b2b 100644 --- a/libtextsecure/test/test.js +++ b/libtextsecure/test/test.js @@ -22054,6 +22054,7 @@ Library.prototype.test = function(obj, type) { }); mocha.setup("bdd"); window.assert = chai.assert; +window.PROTO_ROOT = '../../protos'; (function() { var OriginalReporter = mocha._reporter; @@ -22106,3 +22107,5 @@ function hexToArrayBuffer(str) { array[i] = parseInt(str.substr(i*2, 2), 16); return ret; }; + +window.MockSocket.prototype.addEventListener = function() {}; diff --git a/libtextsecure/test/websocket-resources_test.js b/libtextsecure/test/websocket-resources_test.js index 92755cfbf..c110cfca2 100644 --- a/libtextsecure/test/websocket-resources_test.js +++ b/libtextsecure/test/websocket-resources_test.js @@ -18,7 +18,8 @@ assert.strictEqual(message.response.status, 200); assert.strictEqual(message.response.id.toString(), request_id); done(); - } + }, + addEventListener: function() {}, }; // actual test @@ -58,7 +59,8 @@ assert.strictEqual(message.request.path, '/some/path'); assertEqualArrayBuffers(message.request.body.toArrayBuffer(), new Uint8Array([1,2,3]).buffer); request_id = message.request.id; - } + }, + addEventListener: function() {}, }; // actual test diff --git a/main.js b/main.js index 2e094a416..76b915106 100644 --- a/main.js +++ b/main.js @@ -110,13 +110,41 @@ function captureClicks(window) { window.webContents.on('new-window', handleUrl); } + +const DEFAULT_WIDTH = 800; +const DEFAULT_HEIGHT = 610; +const MIN_WIDTH = 700; +const MIN_HEIGHT = 360; +const BOUNDS_BUFFER = 100; + +function isVisible(window, bounds) { + const boundsX = _.get(bounds, 'x') || 0; + const boundsY = _.get(bounds, 'y') || 0; + const boundsWidth = _.get(bounds, 'width') || DEFAULT_WIDTH; + const boundsHeight = _.get(bounds, 'height') || DEFAULT_HEIGHT; + + // requiring BOUNDS_BUFFER pixels on the left or right side + const rightSideClearOfLeftBound = (window.x + window.width >= boundsX + BOUNDS_BUFFER); + const leftSideClearOfRightBound = (window.x <= boundsX + boundsWidth - BOUNDS_BUFFER); + + // top can't be offscreen, and must show at least BOUNDS_BUFFER pixels at bottom + const topClearOfUpperBound = window.y >= boundsY; + const topClearOfLowerBound = (window.y <= boundsY + boundsHeight - BOUNDS_BUFFER); + + return rightSideClearOfLeftBound + && leftSideClearOfRightBound + && topClearOfUpperBound + && topClearOfLowerBound; +} + function createWindow () { + const screen = electron.screen; const windowOptions = Object.assign({ show: !startInTray, // allow to start minimised in tray - width: 800, - height: 610, - minWidth: 700, - minHeight: 360, + width: DEFAULT_WIDTH, + height: DEFAULT_HEIGHT, + minWidth: MIN_WIDTH, + minHeight: MIN_HEIGHT, autoHideMenuBar: false, webPreferences: { nodeIntegration: false, @@ -124,7 +152,33 @@ function createWindow () { preload: path.join(__dirname, 'preload.js') }, icon: path.join(__dirname, 'images', 'icon_256.png'), - }, windowConfig); + }, _.pick(windowConfig, ['maximized', 'autoHideMenuBar', 'width', 'height', 'x', 'y'])); + + if (!_.isNumber(windowOptions.width) || windowOptions.width < MIN_WIDTH) { + windowOptions.width = DEFAULT_WIDTH; + } + if (!_.isNumber(windowOptions.height) || windowOptions.height < MIN_HEIGHT) { + windowOptions.height = DEFAULT_HEIGHT; + } + if (!_.isBoolean(windowOptions.maximized)) { + delete windowOptions.maximized; + } + if (!_.isBoolean(windowOptions.autoHideMenuBar)) { + delete windowOptions.autoHideMenuBar; + } + + const visibleOnAnyScreen = _.some(screen.getAllDisplays(), function(display) { + if (!_.isNumber(windowOptions.x) || !_.isNumber(windowOptions.y)) { + return false; + } + + return isVisible(windowOptions, _.get(display, 'bounds')); + }); + if (!visibleOnAnyScreen) { + console.log('Location reset needed'); + delete windowOptions.x; + delete windowOptions.y; + } if (windowOptions.fullscreen === false) { delete windowOptions.fullscreen; @@ -175,6 +229,8 @@ function createWindow () { if (config.environment === 'test') { mainWindow.loadURL(prepareURL([__dirname, 'test', 'index.html'])); + } else if (config.environment === 'test-lib') { + mainWindow.loadURL(prepareURL([__dirname, 'libtextsecure', 'test', 'index.html'])); } else { mainWindow.loadURL(prepareURL([__dirname, 'background.html'])); } @@ -195,7 +251,9 @@ function createWindow () { mainWindow.on('close', function (e) { // If the application is terminating, just do the default - if (windowState.shouldQuit() || config.environment === 'test') { + if (windowState.shouldQuit() + || config.environment === 'test' || config.environment === 'test-lib') { + return; } @@ -349,7 +407,7 @@ app.on('before-quit', function() { app.on('window-all-closed', function () { // On OS X it is common for applications and their menu bar // to stay active until the user quits explicitly with Cmd + Q - if (process.platform !== 'darwin' || config.environment === 'test') { + if (process.platform !== 'darwin' || config.environment === 'test' || config.environment === 'test-lib') { app.quit() } }) @@ -394,11 +452,15 @@ ipc.on('restart', function(event) { }); ipc.on("set-auto-hide-menu-bar", function(event, autoHide) { - mainWindow.setAutoHideMenuBar(autoHide); + if (mainWindow) { + mainWindow.setAutoHideMenuBar(autoHide); + } }); ipc.on("set-menu-bar-visibility", function(event, visibility) { - mainWindow.setMenuBarVisibility(visibility); + if (mainWindow) { + mainWindow.setMenuBarVisibility(visibility); + } }); ipc.on("close-about", function() {