diff --git a/app/password_util.js b/app/password_util.js index e2fef063a..b6c5e2b39 100644 --- a/app/password_util.js +++ b/app/password_util.js @@ -1,9 +1,23 @@ const { sha512 } = require('js-sha512'); -const generateHash = (phrase) => sha512(phrase); -const matchesHash = (phrase, hash) => sha512(phrase) === hash; +const generateHash = (phrase) => phrase && sha512(phrase.trim()); +const matchesHash = (phrase, hash) => phrase && sha512(phrase.trim()) === hash.trim(); + +const validatePassword = (phrase) => { + if (typeof phrase !== 'string') { + return 'Password must be a string' + } + + if (phrase && phrase.trim().length < 6) { + return 'Password must be atleast 6 characters long'; + } + + // An empty password is still valid :P + return null; +} module.exports = { - generateHash, - matchesHash, + generateHash, + matchesHash, + validatePassword, }; \ No newline at end of file diff --git a/app/sql.js b/app/sql.js index 1160c50e6..ea2509e17 100644 --- a/app/sql.js +++ b/app/sql.js @@ -1,10 +1,11 @@ +const fs = require('fs'); const path = require('path'); const mkdirp = require('mkdirp'); const rimraf = require('rimraf'); const sql = require('@journeyapps/sqlcipher'); const pify = require('pify'); const uuidv4 = require('uuid/v4'); -const { map, isString, fromPairs, forEach, last } = require('lodash'); +const { map, isString, fromPairs, forEach, last, isEmpty } = require('lodash'); // To get long stack traces // https://github.com/mapbox/node-sqlite3/wiki/API#sqlite3verbose @@ -15,6 +16,11 @@ module.exports = { close, removeDB, removeIndexedDBFiles, + setSQLPassword, + + getPasswordHash, + savePasswordHash, + removePasswordHash, createOrUpdateGroup, getGroupById, @@ -181,15 +187,25 @@ async function getSQLCipherVersion(instance) { } } -const INVALID_KEY = /[^0-9A-Fa-f]/; +const HEX_KEY = /[^0-9A-Fa-f]/; async function setupSQLCipher(instance, { key }) { - const match = INVALID_KEY.exec(key); - if (match) { - throw new Error(`setupSQLCipher: key '${key}' is not valid`); - } + // If the key isn't hex then we need to derive a hex key from it + const deriveKey = HEX_KEY.test(key); // https://www.zetetic.net/sqlcipher/sqlcipher-api/#key - await instance.run(`PRAGMA key = "x'${key}'";`); + const value = deriveKey ? `'${key}'` : `"x'${key}'"` + await instance.run(`PRAGMA key = ${value};`); +} + +async function setSQLPassword(password) { + if (!db) { + throw new Error('setSQLPassword: db is not initialized'); + } + + // If the password isn't hex then we need to derive a key from it + const deriveKey = HEX_KEY.test(password); + const value = deriveKey ? `'${password}'` : `"x'${password}'"` + await db.run(`PRAGMA rekey = ${value};`); } async function updateToSchemaVersion1(currentVersion, instance) { @@ -584,6 +600,25 @@ async function removeIndexedDBFiles() { indexedDBPath = null; } +// Password hash +async function getPasswordHash() { + const item = await getItemById('passHash'); + return item && item.value; +} +async function savePasswordHash(hash) { + if (isEmpty(hash)) { + return removePasswordHash(); + } + + const data = { id: 'passHash', value: hash }; + return createOrUpdateItem(data); +} +async function removePasswordHash() { + return removeItemById('passHash'); +} + +// Groups + const GROUPS_TABLE = 'groups'; async function createOrUpdateGroup(data) { return createOrUpdate(GROUPS_TABLE, data); diff --git a/background.html b/background.html index 482796562..83b1806b8 100644 --- a/background.html +++ b/background.html @@ -603,8 +603,16 @@
Create your Loki Messenger Account
-
Enter a name that will be shown to all your contacts
- +
+
Enter a name that will be shown to all your contacts
+ +
+
+
Type an optional password for added security
+ + +
+

Restore using seed

diff --git a/js/modules/data.js b/js/modules/data.js index 1002a743c..e7eb0614f 100644 --- a/js/modules/data.js +++ b/js/modules/data.js @@ -47,6 +47,8 @@ module.exports = { removeDB, removeIndexedDBFiles, + getPasswordHash, + createOrUpdateGroup, getGroupById, getAllGroupIds, @@ -405,6 +407,12 @@ async function removeIndexedDBFiles() { await channels.removeIndexedDBFiles(); } +// Password hash + +async function getPasswordHash() { + return channels.getPasswordHash(); +} + // Groups async function createOrUpdateGroup(data) { diff --git a/js/views/password_view.js b/js/views/password_view.js index e32cfb5e1..71a941d6c 100644 --- a/js/views/password_view.js +++ b/js/views/password_view.js @@ -26,9 +26,10 @@ }, async onLogin() { const passPhrase = this.$('#passPhrase').val(); + const trimmed = passPhrase ? passPhrase.trim() : passPhrase; this.setError(''); try { - await window.onLogin(passPhrase); + await window.onLogin(trimmed); } catch (e) { this.setError(`Error: ${e}`); } diff --git a/js/views/standalone_registration_view.js b/js/views/standalone_registration_view.js index b2eee33c6..6f32d874a 100644 --- a/js/views/standalone_registration_view.js +++ b/js/views/standalone_registration_view.js @@ -1,4 +1,4 @@ -/* global Whisper, $, getAccountManager, textsecure, i18n, storage, ConversationController */ +/* global Whisper, $, getAccountManager, textsecure, i18n, passwordUtil, ConversationController */ /* eslint-disable more/no-then */ @@ -35,6 +35,13 @@ }); this.$('#mnemonic-language').append(options); this.$('#mnemonic-display-language').append(options); + + this.$passwordInput = this.$('#password'); + this.$passwordConfirmationInput = this.$('#password-confirmation'); + this.$passwordConfirmationInput.hide(); + this.$passwordInputError = this.$('.password-inputs .error'); + + this.onValidatePassword(); }, events: { 'validation input.number': 'onValidation', @@ -48,18 +55,32 @@ 'change #mnemonic-display-language': 'onGenerateMnemonic', 'click #copy-mnemonic': 'onCopyMnemonic', 'click .section-toggle': 'toggleSection', + 'keyup #password': 'onPasswordChange', + 'keyup #password-confirmation': 'onValidatePassword', }, - register(mnemonic) { - this.accountManager - .registerSingleDevice( + async register(mnemonic) { + // Make sure the password is valid + if (this.validatePassword()) { + this.showToast('Invalid password'); + return; + } + + const input = this.trim(this.$passwordInput.val()); + + try { + await window.setPassword(input); + await this.accountManager.registerSingleDevice( mnemonic, this.$('#mnemonic-language').val(), this.$('#display-name').val() - ) - .then(() => { - this.$el.trigger('openInbox'); - }) - .catch(this.log.bind(this)); + ); + this.$el.trigger('openInbox'); + } catch (e) { + if (typeof e === 'string') { + this.showToast(e); + } + this.log(e); + } }, registerWithoutMnemonic() { const mnemonic = this.$('#mnemonic-display').text(); @@ -158,5 +179,52 @@ this.$('.section-toggle').not($target).removeClass('section-toggle-visible') this.$('.section-content').not($next).slideUp('fast'); }, + onPasswordChange() { + const input = this.$passwordInput.val(); + if (!input || input.length === 0) { + this.$passwordConfirmationInput.val(''); + this.$passwordConfirmationInput.hide(); + } else { + this.$passwordConfirmationInput.show(); + } + this.onValidatePassword(); + }, + validatePassword() { + const input = this.trim(this.$passwordInput.val()); + const confirmationInput = this.trim(this.$passwordConfirmationInput.val()); + + const error = passwordUtil.validatePassword(input); + if (error) + return error; + + if (input !== confirmationInput) + return 'Password don\'t match'; + + return null; + }, + onValidatePassword() { + const passwordValidation = this.validatePassword(); + if (passwordValidation) { + this.$passwordInput.addClass('error-input'); + this.$passwordConfirmationInput.addClass('error-input'); + this.$passwordInputError.text(passwordValidation); + this.$passwordInputError.show(); + } else { + this.$passwordInput.removeClass('error-input'); + this.$passwordConfirmationInput.removeClass('error-input'); + this.$passwordInputError.text(''); + this.$passwordInputError.hide(); + } + }, + trim(value) { + return value ? value.trim() : value; + }, + showToast(message) { + const toast = new Whisper.MessageToastView({ + message, + }); + toast.$el.appendTo(this.$el); + toast.render(); + }, }); })(); diff --git a/main.js b/main.js index a95b94a24..50e4f2b26 100644 --- a/main.js +++ b/main.js @@ -171,7 +171,7 @@ function captureClicks(window) { } const DEFAULT_WIDTH = 800; -const DEFAULT_HEIGHT = 610; +const DEFAULT_HEIGHT = 710; const MIN_WIDTH = 640; const MIN_HEIGHT = 360; const BOUNDS_BUFFER = 100; @@ -709,13 +709,12 @@ app.on('ready', async () => { const key = getDefaultSQLKey(); - // If we have a password set then show the password window - // Otherwise show the main window - const passHash = userConfig.get('passHash'); - if (passHash) { - showPasswordWindow(); - } else { + // Try to show the main window with the default key + // If that fails then show the password window + try { await showMainWindow(key); + } catch (e) { + showPasswordWindow(); } }); @@ -950,26 +949,45 @@ ipc.on('update-tray-icon', (event, unreadCount) => { // Password screen related IPC calls ipc.on('password-window-login', async (event, passPhrase) => { - const sendError = (e) => event.sender.send('password-window-login-response', e); + const sendResponse = (e) => event.sender.send('password-window-login-response', e); - // Check if the phrase matches with the hash we have stored - const hash = userConfig.get('passHash'); - const hashMatches = passPhrase && passwordUtil.matchesHash(passPhrase, hash); - if (hash && !hashMatches) { - sendError('Invalid password'); - return; - } - - // If we don't have a hash then use the default sql key to unlock the db - const key = hash ? passPhrase : getDefaultSQLKey(); try { - await showMainWindow(key); + await showMainWindow(passPhrase); + sendResponse(); if (passwordWindow) { passwordWindow.close(); passwordWindow = null; } } catch (e) { - sendError('Failed to decrypt SQL database'); + sendResponse('Invalid password'); + } +}); + +ipc.on('set-password', async (event, passPhrase, oldPhrase) => { + const sendResponse = (e) => event.sender.send('set-password-response', e); + + try { + // Check if the hash we have stored matches the hash of the old passphrase. + const hash = await sql.getPasswordHash(); + const hashMatches = oldPhrase && passwordUtil.matchesHash(oldPhrase, hash); + if (hash && !hashMatches) { + sendResponse('Failed to set password: Old password provided is invalid'); + return; + } + + if (_.isEmpty(passPhrase)) { + const defaultKey = getDefaultSQLKey(); + await sql.setSQLPassword(defaultKey); + await sql.removePasswordHash(); + } else { + await sql.setSQLPassword(passPhrase); + const newHash = passwordUtil.generateHash(passPhrase); + await sql.savePasswordHash(newHash); + } + + sendResponse(); + } catch (e) { + sendResponse('Failed to set password'); } }); diff --git a/password.html b/password.html index 557c74b7e..ab818dae3 100644 --- a/password.html +++ b/password.html @@ -21,7 +21,7 @@

{{ title }}

diff --git a/preload.js b/preload.js index 9c5ea103e..e55fedc0a 100644 --- a/preload.js +++ b/preload.js @@ -49,6 +49,17 @@ const localeMessages = ipc.sendSync('locale-data'); window.setBadgeCount = count => ipc.send('set-badge-count', count); +// Set the password for the database +window.setPassword = (passPhrase, oldPhrase) => new Promise((resolve, reject) => { + ipc.once('set-password-response', (event, error) => { + if (error) { + return reject(error); + } + return resolve(); + }); + ipc.send('set-password', passPhrase, oldPhrase); +}); + // We never do these in our code, so we'll prevent it everywhere window.open = () => null; // eslint-disable-next-line no-eval, no-multi-assign @@ -273,6 +284,7 @@ window.libphonenumber.PhoneNumberFormat = require('google-libphonenumber').Phone window.loadImage = require('blueimp-load-image'); window.getGuid = require('uuid/v4'); window.profileImages = require('./app/profile_images'); +window.passwordUtil = require('./app/password_util'); window.React = require('react'); window.ReactDOM = require('react-dom'); diff --git a/stylesheets/_global.scss b/stylesheets/_global.scss index b61fbf51c..3321781a2 100644 --- a/stylesheets/_global.scss +++ b/stylesheets/_global.scss @@ -919,7 +919,7 @@ textarea { } } - .display-name-header { + .input-header { margin-bottom: 8px; font-size: 14px; } @@ -977,8 +977,26 @@ textarea { } } + .password-inputs { + input { + margin-bottom: 0.5em; + } + + .error { + margin-bottom: 1em; + } + + .error-input { + border: 3px solid $color-vermilion; + + &:focus { + outline: none; + } + } + } + @media (min-height: 750px) and (min-width: 700px) { - .display-name-header { + .input-header { font-size: 18px; } diff --git a/test/app/password_util_test.js b/test/app/password_util_test.js index c1bf27a44..8256fe6b5 100644 --- a/test/app/password_util_test.js +++ b/test/app/password_util_test.js @@ -27,4 +27,32 @@ describe('Password Util', () => { assert.isFalse(passwordUtil.matchesHash('phrase2', hash)); }); }); + + describe('password validation', () => { + it('should return nothing if password is valid', () => { + const valid = [ + '123456', + '1a5b3C6g', + 'ABC#DE#F$IJ', + 'AabcDegf', + ]; + valid.forEach(pass => { + assert.isNull(passwordUtil.validatePassword(pass)); + }); + }); + + it('should return an error string if password is invalid', () => { + const invalid = [ + 0, + 123456, + [], + {}, + '123', + '1234$', + ]; + invalid.forEach(pass => { + assert.isNotNull(passwordUtil.validatePassword(pass)); + }); + }); + }); });