diff --git a/Gruntfile.js b/Gruntfile.js index f9ed59621..9d73c081a 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -175,6 +175,17 @@ module.exports = function(grunt) { 'src/js/trezor-url.js', 'bower_components/trezor-connect/connect.js', 'node_modules/bezier-easing/dist/bezier-easing.min.js', + + 'bower_components/crypto-js/core.js', + 'bower_components/crypto-js/enc-base64.js', + 'bower_components/crypto-js/hmac.js', + 'bower_components/crypto-js/md5.js', + 'bower_components/crypto-js/sha1.js', + 'bower_components/crypto-js/evpkdf.js', + 'bower_components/crypto-js/cipher-core.js', + 'bower_components/crypto-js/aes.js', + 'bower_components/crypto-js/pbkdf2.js', + 'node_modules/cordova-plugin-qrscanner/dist/cordova-plugin-qrscanner-lib.min.js' ], dest: 'www/js/app.js' diff --git a/bower.json b/bower.json index 08a82d9b8..a66685254 100644 --- a/bower.json +++ b/bower.json @@ -11,6 +11,7 @@ "angular-gettext": "2.2.1", "angular-moment": "0.10.1", "angular-qrcode": "bitpay/angular-qrcode#~6.3.0", + "crypto-js": "^3.1.9", "ionic": "https://github.com/ionic-team/ionic-v1.git", "moment": "2.10.3", "ng-lodash": "0.2.3", diff --git a/src/js/services/encryptionService.js b/src/js/services/encryptionService.js new file mode 100644 index 000000000..37b1b2c3b --- /dev/null +++ b/src/js/services/encryptionService.js @@ -0,0 +1,164 @@ + +(function() { + 'use strict'; + + + angular.module('copayApp.services').factory('encryptionService', function($log, secureStorageService) { + var keySize = 512; + var iterations = 1500; + var storageKey = 'encryptionKey'; + + var service = { + decrypt: decrypt, + encrypt: encrypt, + removeKeyIfExists: removeKeyIfExists + }; + return service; + + /** + * Returns a CryptoJS.WordArray + */ + function _generateKey() { + var salt = CryptoJS.lib.WordArray.random(128/8); + var passphrase = CryptoJS.lib.WordArray.random(128/8); + var key = CryptoJS.PBKDF2(passphrase, salt, { keySize: keySize/32, iterations: iterations }); + + $log.debug('Generated key: ' + key); + return key; + } + + /** + * + * @param {*} cb + */ + function _getOrCreateKey(cb) { + + secureStorageService.get(storageKey, function onKeyRetrieved(keyErr, keyHex) { + if (keyErr) { + cb(keyErr, null); + return; + } + + if (keyHex) { + var key = CryptoJS.enc.Hex.parse(keyHex); + cb(null, key); + return; + } + + key = _generateKey(); + var keyHex = CryptoJS.enc.Hex.stringify(key); + secureStorageService.set(storageKey, keyHex, function onKeyStored(storeErr) { + if (storeErr) { + $log.error('Error storing key.', storeErr); + cb(storeErr, null); + return; + } + + cb(null, key); + }); + + }); + }; + + /** + * + * @param {string, Base64 encoded} str + * @param {CryptoJS.WordArray} key + * @param {string, hex} iv + */ + function _decryptUsingCryptoJS(str, key, iv) { + $log.debug('decrypt() str: ' + str); + $log.debug('decrypt() using iv:' + iv + ', key: ' + JSON.stringify(key)); + + var ivWords = CryptoJS.enc.Hex.parse(iv); + var plaintext = CryptoJS.AES.decrypt(str, key, { iv: ivWords }); + $log.debug('plaintext', JSON.stringify(plaintext)); + + var plaintextWords = CryptoJS.lib.WordArray.create(); + plaintextWords.init(plaintext.words, plaintext.sigBytes); + $log.debug('plaintextWords', JSON.stringify(plaintextWords)); + var plaintextString = plaintextWords.toString(CryptoJS.enc.Utf8); + $log.debug('plaintextString: ', JSON.stringify(plaintextString)); + + return plaintextString; + } + + /** + * Generates its own Initialization Vector, which is returned. + * @param {string, Base64 encoded} str + * @param {CryptoJS.WordArray} key + * @returns {*} The ciphertext created, and the IV used. + */ + function _encryptUsingCryptoJS(str, key) { + var iv = CryptoJS.lib.WordArray.random(16); + + var cipherParams = CryptoJS.AES.encrypt(str, key, { iv: iv }); + var ciphertextWords = cipherParams.ciphertext.toString(CryptoJS.enc.Base64); + var ivHex = iv.toString(CryptoJS.enc.Hex); + + // Just for testing - do we get back what we put in? + /* + decrypt(ciphertext, {iv: ivHex}, function onDecryptionTest(err, decrypted){ + if (err) { + $log.error('Failed to decrypt encrypted.', err); + + } else { + $log.debug('Freshly decrypted:', JSON.stringify(decrypted)); + } + }); + */ + + return { + ciphertext: ciphertextWords.toString(CryptoJS.enc.Base64), + opts: { + iv: ivHex + } + }; + } + + function decrypt(str, opts, cb) { + _getOrCreateKey(function onKey(err, key) { + if (err) { + $log.error('Failed to get or create key.', err); + cb(err, null); + return; + } + + var decrypted; + try { + decrypted = _decryptUsingCryptoJS(str, key, opts.iv); + } catch (e) { + // Can get this when using the wrong key: Malformed UTF-8 data + $log.error('Error when decrypting.', e); + cb(e, null); + return; + } + cb(null, decrypted); + }); + }; + + function encrypt(str, cb) { + + _getOrCreateKey(function onKey(err, key){ + if (err) { + cb(err, null); + return; + } + + var encrypted = _encryptUsingCryptoJS(str, key); + cb(null, encrypted); + }); + }; + + function removeKeyIfExists() { + secureStorageService.remove(storageKey, function onKeyRemoved(err){ + if (err) { + $log.Error('Error removing key.', err); + return; + } + $log.debug('Key removed.'); + }); + } + + }); +})(); \ No newline at end of file diff --git a/src/js/services/jsonEncryptionService.js b/src/js/services/jsonEncryptionService.js new file mode 100644 index 000000000..54d871d11 --- /dev/null +++ b/src/js/services/jsonEncryptionService.js @@ -0,0 +1,75 @@ +(function() { + 'use strict'; + + angular + .module('copayApp.services') + .factory('jsonEncryptionService', jsonEncryptionService); + + function jsonEncryptionService($log) { + var currentVersion = 1; + + var service = { + isEncrypted: isEncrypted, + parse: parse, + stringify: stringify + }; + return service; + + function isEncrypted(jsonStr) { + try { + var jsonObj = JSON.parse(jsonStr); + } catch (e) { + $log.error('Failed to parse JSON when looking for encypted data.', e); + return false; + } + return jsonObj.version && jsonObj.encryptedData; + } + + function parse(jsonStr) { + + var jsonObj = JSON.parse(jsonStr); + + if (!(jsonObj.version && jsonObj.version === currentVersion)) { + throw new Error('Incompatible version.'); + } + + var encryptedData = jsonObj.encryptedData; + // extract ciphertext from json object, and create cipher params object + //var ciphertext = CryptoJS.enc.Base64.parse(encryptedData.ciphertext) + //var iv = CryptoJS.enc.Hex.parse(encryptedData.iv); + var ciphertext = encryptedData.ciphertext; + var iv = encryptedData.iv; + + return { + ciphertext: ciphertext, + opts: { + iv: iv + } + } + } + + /** + * + * @param {string, Base64 encoded} ciphertext + * @param {*} opts So it flexible enough to handle other schemes in future. + * @throws If cipherParams does not include the iv. + */ + function stringify(ciphertext, opts) { + var iv = opts.iv; + + if(!iv) { + throw new Error('Must include iv.'); + } + + var encryptedData = { + ciphertext: ciphertext, + iv: iv + }; + + return JSON.stringify({ + version: currentVersion, + encryptedData: encryptedData + }); + } + }; +})(); \ No newline at end of file diff --git a/src/js/services/mobileSecureStorageService.js b/src/js/services/mobileSecureStorageService.js index f9994fdf8..473b62885 100644 --- a/src/js/services/mobileSecureStorageService.js +++ b/src/js/services/mobileSecureStorageService.js @@ -47,7 +47,7 @@ angular.module('copayApp.services').factory('mobileSecureStorageService', functi function (error) { $log.debug('mss get failed. ' + error); if (error.message === 'Failure in SecureStorage.get() - The specified item could not be found in the keychain' || // iOS - error.message === 'Key [_SS_profile] not found.') { // Android + error.message === 'Key [_SS_' + key + '] not found.') { // Android // The callback expects no error, but also no value, if it cannot be found. cb(null, null); } else { @@ -57,6 +57,32 @@ angular.module('copayApp.services').factory('mobileSecureStorageService', functi key); }; + root.remove = function(key, cb) { + + if (!platformInfo.isMobile) { + cb(new Error('mobileSecureStorageService is only available on mobile.')); + } + + if (!isReady) { + if (initialisationFailed) { + cb(new Error('mobileSecureStorageService initialisation failed.')); + } else { + pending.push(function(){ root.remove(key, cb); }); + } + return; + } + + storage.remove( + function (value) { + cb(); + }, + function (error) { + cb(new Error(error)); + }, + key); + + }; + root.set = function(key, value, cb) { if (!platformInfo.isMobile) { diff --git a/src/js/services/secureStorageService.js b/src/js/services/secureStorageService.js index c066109c2..b4d9a6fc3 100644 --- a/src/js/services/secureStorageService.js +++ b/src/js/services/secureStorageService.js @@ -16,7 +16,17 @@ angular.module('copayApp.services').factory('secureStorageService', function(des } else { // Browser localStorageService.get(alteredKeyIndicatingDesireForSecureStorage(k), cb); } - } + }; + + root.remove = function(k, cb) { + if (platformInfo.isMobile) { + mobileSecureStorageService.remove(k, cb); + } else if (platformInfo.isNW) { + desktopSecureStorageService.remove(k, cb); + } else { // Browser + localStorageService.remove(alteredKeyIndicatingDesireForSecureStorage(k), cb); + } + }; root.set = function(k, v, cb) { if (platformInfo.isMobile) { @@ -26,7 +36,7 @@ angular.module('copayApp.services').factory('secureStorageService', function(des } else { // Browser localStorageService.set(alteredKeyIndicatingDesireForSecureStorage(k), v, cb); } - } + }; return root; }); \ No newline at end of file diff --git a/src/js/services/storageService.js b/src/js/services/storageService.js index a2d85950b..ba19d6439 100644 --- a/src/js/services/storageService.js +++ b/src/js/services/storageService.js @@ -1,6 +1,6 @@ 'use strict'; angular.module('copayApp.services') - .factory('storageService', function(appConfigService, logHeader, fileStorageService, localStorageService, sjcl, $log, lodash, platformInfo, secureStorageService, $timeout) { + .factory('storageService', function(appConfigService, encryptionService, jsonEncryptionService, logHeader, fileStorageService, localStorageService, sjcl, $log, lodash, platformInfo, secureStorageService, $timeout) { var root = {}; var storage; @@ -32,7 +32,7 @@ angular.module('copayApp.services') // This is only used in Copay, we used to encrypt profile // using device's UUID. - var decryptOnMobile = function(text, cb) { + var copayDecryptOnMobile = function(text, cb) { var json; try { json = JSON.parse(text); @@ -121,11 +121,22 @@ angular.module('copayApp.services') root.storeProfile = function(profile, cb) { var profileString = profile.toObj(); - if (platformInfo.isNW) { - storage.set('profile', profileString, cb); - } else { - secureStorageService.set('profile', profileString, cb); - } + encryptionService.encrypt(profileString, function onProfileEncrypted(encryptionErr, encryptedProfile){ + if (encryptionErr) { + $log.error('Failed to encrypt profile.', encryptionErr); + cb(encryptionErr, null); + return; + } + + $log.debug('storing profile ciphertext:', JSON.stringify(encryptedProfile.ciphertext)); + var persistentProfileStr = jsonEncryptionService.stringify( + encryptedProfile.ciphertext, + encryptedProfile.opts + ); + + + storage.set('profile', persistentProfileStr, cb); + }); }; /** @@ -150,7 +161,7 @@ angular.module('copayApp.services') return cb(null, null); } - decryptOnMobile(profileStr, function(decryptErr, decryptedStr) { + copayDecryptOnMobile(profileStr, function(decryptErr, decryptedStr) { if (decryptErr) return cb(decryptErr, null); var profile; try { @@ -200,53 +211,113 @@ angular.module('copayApp.services') }); }; + function _migrateUnencryptedProfile(profileStr, cb) { + copayDecryptOnMobile(profileStr, function(decryptErr, decryptedStr) { + if (decryptErr) return cb(decryptErr, null); + var profile; + try { + profile = Profile.fromString(decryptedStr); + } catch (e) { + $log.error('Could not read profile:', e); + return cb(new Error('Could not read profile.'), null); + } + + // This is the only change to the contents of the profile. + profile.setAppVersion(appConfigService.version); + var newProfileStr = profile.toObj(); + + encryptionService.encrypt(newProfileStr, function onProfileEncrypted(encryptErr, encryptedProfile){ + if (encryptErr) { + $log.error('Failed to encrypt profile.', encryptErr); + cb(encryptErr, null); + return; + } + + var persistentProfileStr; + try { + persistentProfileStr = jsonEncryptionService.stringify(encryptedProfile.ciphertext, encryptedProfile.opts); + } catch(e) { + $log.error('Failed to stringify to encrypted profile.', e); + cb(e, null); + return; + } + + storage.set('profile', persistentProfileStr, function onEncryptedProfileStored(setErr) { + if (setErr) { + $log.error('Failed to store encrypted profile.', setErr); + cb(setErr, null); + return; + } + + cb (null, profile); + }); + }); + }); + } + /** * * @param {getProfileCallback} cb */ root.getProfile = function(cb) { - if (platformInfo.isNW) { - storage.get('profile', function(getErr, getStr) { - _onOldProfileRetrieved(getErr, getStr, cb); - }); - return - } - - secureStorageService.get('profile', function(secureErr, secureStr) { - var secureProfile; - var oldProfile; - - if (secureErr) { - return cb(secureErr, null); + $log.debug('getProfile()'); + storage.get('profile', function onProfileRetrieved(getErr, profileStr){ + if (getErr) { + $log.error(getErr); + return cb(getErr, null); } - if (secureStr) { + if (!profileStr) { + $log.debug('No string loaded, returning nothing.'); + // Don't want to use the same key as a previous installation + encryptionService.removeKeyIfExists(); + return cb(null, null); + } + + var isEncrypted = jsonEncryptionService.isEncrypted(profileStr); + if (isEncrypted) { + $log.debug('profile was encrypted.'); + $log.debug('profileStr: ', profileStr); + + var encryptedProfileObject; try { - secureProfile = Profile.fromString(secureStr); - $log.debug('profile: ' + JSON.stringify(secureProfile)); + encryptedProfileObject = jsonEncryptionService.parse(profileStr); } catch (e) { - $log.error(e); - return cb(e, null); + $log.error('Failed to parse encrypted profile.', e); + cb(e, null); + return; } + + $log.debug('profileStr after JSON: ', JSON.stringify(encryptedProfileObject)); + + encryptionService.decrypt( + encryptedProfileObject.ciphertext, + encryptedProfileObject.opts, + function onDecrypted(decryptionError, decryptedProfile) { + if (decryptionError) { + $log.error('Failed to decrypt profile'); + cb(decryptionError, null); + return + } + + $log.debug('Decrypted profile:', JSON.stringify(decryptedProfile)); + + var profileObj = Profile.fromString(decryptedProfile); + cb(null, profileObj); + }); + + } else { + _migrateUnencryptedProfile(profileStr, function onProfileMigrated(migrationErr, migratedProfile){ + if (migrationErr) { + $log.error('Failed to migrate the profile.', migrationErr); + cb(migraionErr, null); + return; + } + + cb(null, migratedProfile); + }); } - storage.get('profile', function(getErr, getStr) { - _onOldProfileRetrieved(getErr, getStr, function(oldErr, oldProfile){ - if (oldErr) { - return cb(oldErr, null); - } - - if (!oldProfile) { - if (secureProfile) { - return cb(null, secureProfile); - } else { - // No profiles found. No errors either. - return cb(null, null); - } - } - _migrateProfiles(oldProfile, secureProfile, cb); - }); - }); }); };