diff --git a/integration/test/ParseUserTest.js b/integration/test/ParseUserTest.js index 6590ac8f8..e5d5fdf52 100644 --- a/integration/test/ParseUserTest.js +++ b/integration/test/ParseUserTest.js @@ -936,6 +936,32 @@ describe('Parse User', () => { expect(user.get('authData').facebook.id).toBe('test'); }); + it('can encrypt user', async () => { + Parse.User.enableUnsafeCurrentUser(); + Parse.enableEncryptedUser(); + Parse.secret = 'My Secret Key'; + const user = new Parse.User(); + user.setUsername('usernameENC'); + user.setPassword('passwordENC'); + await user.signUp(); + + const path = Parse.Storage.generatePath('currentUser'); + const encryptedUser = Parse.Storage.getItem(path); + + const crypto = Parse.CoreManager.getCryptoController(); + const decryptedUser = crypto.decrypt(encryptedUser, Parse.CoreManager.get('ENCRYPTED_KEY')); + expect(JSON.parse(decryptedUser).objectId).toBe(user.id); + + const currentUser = Parse.User.current(); + expect(currentUser).toEqual(user); + + const currentUserAsync = await Parse.User.currentAsync(); + expect(currentUserAsync).toEqual(user); + await Parse.User.logOut(); + Parse.CoreManager.set('ENCRYPTED_USER', false); + Parse.CoreManager.set('ENCRYPTED_KEY', null); + }); + it('fix GHSA-wvh7-5p38-2qfc', async () => { Parse.User.enableUnsafeCurrentUser(); const user = new Parse.User(); diff --git a/package-lock.json b/package-lock.json index 3bf5be6db..5899a8492 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3758,6 +3758,11 @@ "randomfill": "^1.0.3" } }, + "crypto-js": { + "version": "3.1.9-1", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-3.1.9-1.tgz", + "integrity": "sha1-/aGedh/Ad+Af+/3G6f38WeiAbNg=" + }, "cssom": { "version": "0.3.8", "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", @@ -10379,9 +10384,9 @@ "@apollographql/graphql-playground-html": "1.6.24", "@parse/fs-files-adapter": "1.0.1", "@parse/push-adapter": "3.2.0", - "@parse/s3-files-adapter": "1.3.0", + "@parse/s3-files-adapter": "1.4.0", "@parse/simple-mailgun-adapter": "1.1.0", - "apollo-server-express": "2.9.12", + "apollo-server-express": "2.9.14", "bcrypt": "3.0.7", "bcryptjs": "2.4.3", "body-parser": "1.19.0", @@ -10394,25 +10399,26 @@ "graphql-list-fields": "2.0.2", "graphql-relay": "^0.6.0", "graphql-tools": "^4.0.5", - "graphql-upload": "8.1.0", + "graphql-upload": "9.0.0", "intersect": "1.0.1", "jsonwebtoken": "8.5.1", "ldapjs": "1.0.2", "lodash": "4.17.15", "lru-cache": "5.1.1", "mime": "2.4.4", - "mongodb": "3.3.2", + "mongodb": "3.4.1", "node-rsa": "1.0.7", - "parse": "2.9.1", + "parse": "2.10.0", "pg-promise": "10.3.1", "pluralize": "^8.0.0", "redis": "2.8.0", - "semver": "6.3.0", + "semver": "7.1.1", "subscriptions-transport-ws": "0.9.16", "tv4": "1.3.0", "uuid": "3.3.3", "winston": "3.2.1", - "winston-daily-rotate-file": "3.10.0" + "winston-daily-rotate-file": "3.10.0", + "ws": "7.2.1" }, "dependencies": { "commander": { diff --git a/package.json b/package.json index 14af4847c..f0bca7a9c 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "dependencies": { "@babel/runtime": "7.7.7", "@babel/runtime-corejs3": "7.7.7", + "crypto-js": "3.1.9-1", "uuid": "3.3.3", "ws": "7.2.1", "xmlhttprequest": "1.8.0" diff --git a/src/CoreManager.js b/src/CoreManager.js index 4621dbcb1..73e863128 100644 --- a/src/CoreManager.js +++ b/src/CoreManager.js @@ -33,6 +33,10 @@ type ConfigController = { get: () => Promise; save: (attrs: { [key: string]: any }) => Promise; }; +type CryptoController = { + encrypt: (obj: any, secretKey: string) => string; + decrypt: (encryptedText: string, secretKey: any) => string; +}; type FileController = { saveFile: (name: string, source: FileSource, options: FullOptions) => Promise; saveBase64: (name: string, source: FileSource, options: FullOptions) => Promise; @@ -176,13 +180,15 @@ const config: Config & { [key: string]: mixed } = { SERVER_AUTH_TYPE: null, SERVER_AUTH_TOKEN: null, LIVEQUERY_SERVER_URL: null, + ENCRYPTED_KEY: null, VERSION: 'js' + require('../package.json').version, APPLICATION_ID: null, JAVASCRIPT_KEY: null, MASTER_KEY: null, USE_MASTER_KEY: false, PERFORM_USER_REWRITE: true, - FORCE_REVOCABLE_SESSION: false + FORCE_REVOCABLE_SESSION: false, + ENCRYPTED_USER: false }; function requireMethods(name: string, methods: Array, controller: any) { @@ -234,6 +240,15 @@ module.exports = { return config['ConfigController']; }, + setCryptoController(controller: CryptoController) { + requireMethods('CryptoController', ['encrypt', 'decrypt'], controller); + config['CryptoController'] = controller; + }, + + getCryptoController(): CryptoController { + return config['CryptoController']; + }, + setFileController(controller: FileController) { requireMethods('FileController', ['saveFile', 'saveBase64'], controller); config['FileController'] = controller; diff --git a/src/CryptoController.js b/src/CryptoController.js new file mode 100644 index 000000000..555eb9736 --- /dev/null +++ b/src/CryptoController.js @@ -0,0 +1,16 @@ +import AES from 'crypto-js/aes'; +import ENC from 'crypto-js/enc-utf8'; + +const CryptoController = { + encrypt(obj: any, secretKey: string): ?string { + const encrypted = AES.encrypt(JSON.stringify(obj), secretKey); + return encrypted.toString(); + }, + + decrypt(encryptedText: string, secretKey: string): ?string { + const decryptedStr = AES.decrypt(encryptedText, secretKey).toString(ENC); + return decryptedStr; + }, +}; + +module.exports = CryptoController; diff --git a/src/Parse.js b/src/Parse.js index c38ef2a62..a3fc29da6 100644 --- a/src/Parse.js +++ b/src/Parse.js @@ -10,6 +10,7 @@ import decode from './decode'; import encode from './encode'; import CoreManager from './CoreManager'; +import CryptoController from './CryptoController'; import InstallationController from './InstallationController'; import * as ParseOp from './ParseOp'; import RESTController from './RESTController'; @@ -169,6 +170,34 @@ Object.defineProperty(Parse, 'liveQueryServerURL', { CoreManager.set('LIVEQUERY_SERVER_URL', value); } }); + +/** + * @member Parse.encryptedUser + * @type boolean + * @static + */ +Object.defineProperty(Parse, 'encryptedUser', { + get() { + return CoreManager.get('ENCRYPTED_USER'); + }, + set(value) { + CoreManager.set('ENCRYPTED_USER', value); + } +}); + +/** + * @member Parse.secret + * @type string + * @static + */ +Object.defineProperty(Parse, 'secret', { + get() { + return CoreManager.get('ENCRYPTED_KEY'); + }, + set(value) { + CoreManager.set('ENCRYPTED_KEY', value); + } +}); /* End setters */ Parse.ACL = require('./ParseACL').default; @@ -255,6 +284,27 @@ Parse.dumpLocalDatastore = function() { return Parse.LocalDatastore._getAllContents(); } } + +/** + * Enable the current user encryption. + * This must be called before login any user. + * + * @static + */ +Parse.enableEncryptedUser = function() { + Parse.encryptedUser = true; +} + +/** + * Flag that indicates whether Encrypted User is enabled. + * + * @static + */ +Parse.isEncryptedUserEnabled = function() { + return Parse.encryptedUser; +} + +CoreManager.setCryptoController(CryptoController); CoreManager.setInstallationController(InstallationController); CoreManager.setRESTController(RESTController); diff --git a/src/ParseUser.js b/src/ParseUser.js index b50857132..2752d890b 100644 --- a/src/ParseUser.js +++ b/src/ParseUser.js @@ -872,8 +872,13 @@ const DefaultController = { delete json.password; json.className = '_User'; + let userData = JSON.stringify(json); + if (CoreManager.get('ENCRYPTED_USER')) { + const crypto = CoreManager.getCryptoController(); + userData = crypto.encrypt(json, CoreManager.get('ENCRYPTED_KEY')) + } return Storage.setItemAsync( - path, JSON.stringify(json) + path, userData ).then(() => { return user; }); @@ -918,6 +923,10 @@ const DefaultController = { currentUserCache = null; return null; } + if (CoreManager.get('ENCRYPTED_USER')) { + const crypto = CoreManager.getCryptoController(); + userData = crypto.decrypt(userData, CoreManager.get('ENCRYPTED_KEY')); + } userData = JSON.parse(userData); if (!userData.className) { userData.className = '_User'; @@ -954,6 +963,10 @@ const DefaultController = { currentUserCache = null; return Promise.resolve(null); } + if (CoreManager.get('ENCRYPTED_USER')) { + const crypto = CoreManager.getCryptoController(); + userData = crypto.decrypt(userData.toString(), CoreManager.get('ENCRYPTED_KEY')); + } userData = JSON.parse(userData); if (!userData.className) { userData.className = '_User'; diff --git a/src/__tests__/Parse-test.js b/src/__tests__/Parse-test.js index de3a1bc28..cfdd10dcd 100644 --- a/src/__tests__/Parse-test.js +++ b/src/__tests__/Parse-test.js @@ -8,8 +8,10 @@ */ jest.dontMock('../CoreManager'); +jest.dontMock('../CryptoController'); jest.dontMock('../Parse'); jest.dontMock('../LocalDatastore'); +jest.dontMock('crypto-js/aes'); const CoreManager = require('../CoreManager'); const Parse = require('../Parse'); @@ -109,4 +111,19 @@ describe('Parse module', () => { LDS = await Parse.dumpLocalDatastore(); expect(LDS).toEqual({ key: 'value' }); }); + + it('can enable encrypter CurrentUser', () => { + jest.spyOn(console, 'log').mockImplementationOnce(() => {}); + process.env.PARSE_BUILD = 'browser'; + Parse.encryptedUser = false; + Parse.enableEncryptedUser(); + expect(Parse.encryptedUser).toBe(true); + expect(Parse.isEncryptedUserEnabled()).toBe(true); + }); + + it('can set an encrypt token as String', () => { + Parse.secret = 'My Super secret key'; + expect(CoreManager.get('ENCRYPTED_KEY')).toBe('My Super secret key'); + expect(Parse.secret).toBe('My Super secret key'); + }); }); diff --git a/src/__tests__/ParseUser-test.js b/src/__tests__/ParseUser-test.js index fbefdf1af..3331b5d6a 100644 --- a/src/__tests__/ParseUser-test.js +++ b/src/__tests__/ParseUser-test.js @@ -9,6 +9,7 @@ jest.dontMock('../AnonymousUtils'); jest.dontMock('../CoreManager'); +jest.dontMock('../CryptoController'); jest.dontMock('../decode'); jest.dontMock('../encode'); jest.dontMock('../isRevocableSession'); @@ -26,6 +27,8 @@ jest.dontMock('../StorageController.default'); jest.dontMock('../TaskQueue'); jest.dontMock('../unique'); jest.dontMock('../UniqueInstanceStateController'); +jest.dontMock('crypto-js/aes'); +jest.dontMock('crypto-js/enc-utf8'); jest.mock('uuid/v4', () => { let value = 0; @@ -34,6 +37,7 @@ jest.mock('uuid/v4', () => { jest.dontMock('./test_helpers/mockXHR'); const CoreManager = require('../CoreManager'); +const CryptoController = require('../CryptoController'); const LocalDatastore = require('../LocalDatastore'); const ParseObject = require('../ParseObject').default; const ParseUser = require('../ParseUser').default; @@ -43,6 +47,7 @@ const AnonymousUtils = require('../AnonymousUtils').default; CoreManager.set('APPLICATION_ID', 'A'); CoreManager.set('JAVASCRIPT_KEY', 'B'); +CoreManager.setCryptoController(CryptoController); function flushPromises() { return new Promise(resolve => setImmediate(resolve)); @@ -1010,6 +1015,111 @@ describe('ParseUser', () => { expect(authProvider).toBe('testProvider'); }); + it('can encrypt user', async () => { + CoreManager.set('ENCRYPTED_USER', true); + CoreManager.set('ENCRYPTED_KEY', 'hello'); + + ParseUser.enableUnsafeCurrentUser(); + ParseUser._clearCache(); + Storage._clear(); + let u = null; + CoreManager.setRESTController({ + request(method, path, body) { + expect(method).toBe('GET'); + expect(path).toBe('login'); + expect(body.username).toBe('username'); + expect(body.password).toBe('password'); + + return Promise.resolve({ + objectId: 'uid2', + username: 'username', + sessionToken: '123abc' + }, 200); + }, + ajax() {} + }); + u = await ParseUser.logIn('username', 'password'); + // Clear cache to read from disk + ParseUser._clearCache(); + + expect(u.id).toBe('uid2'); + expect(u.getSessionToken()).toBe('123abc'); + expect(u.isCurrent()).toBe(true); + expect(u.authenticated()).toBe(true); + + const currentUser = ParseUser.current(); + expect(currentUser.id).toBe('uid2'); + + ParseUser._clearCache(); + + const currentUserAsync = await ParseUser.currentAsync(); + expect(currentUserAsync.id).toEqual('uid2'); + + const path = Storage.generatePath('currentUser'); + const encryptedUser = Storage.getItem(path); + const crypto = CoreManager.getCryptoController(); + const decryptedUser = crypto.decrypt(encryptedUser, 'hello'); + expect(JSON.parse(decryptedUser).objectId).toBe(u.id); + + CoreManager.set('ENCRYPTED_USER', false); + CoreManager.set('ENCRYPTED_KEY', null); + Storage._clear(); + }); + + it('can encrypt user with custom CryptoController', async () => { + CoreManager.set('ENCRYPTED_USER', true); + CoreManager.set('ENCRYPTED_KEY', 'hello'); + const ENCRYPTED_DATA = 'encryptedString'; + + ParseUser.enableUnsafeCurrentUser(); + ParseUser._clearCache(); + Storage._clear(); + let u = null; + CoreManager.setRESTController({ + request(method, path, body) { + expect(method).toBe('GET'); + expect(path).toBe('login'); + expect(body.username).toBe('username'); + expect(body.password).toBe('password'); + + return Promise.resolve({ + objectId: 'uid2', + username: 'username', + sessionToken: '123abc' + }, 200); + }, + ajax() {} + }); + const CustomCrypto = { + encrypt(obj, secretKey) { + expect(secretKey).toBe('hello'); + return ENCRYPTED_DATA; + }, + decrypt(encryptedText, secretKey) { + expect(encryptedText).toBe(ENCRYPTED_DATA); + expect(secretKey).toBe('hello'); + return JSON.stringify(u.toJSON()); + }, + }; + CoreManager.setCryptoController(CustomCrypto); + u = await ParseUser.logIn('username', 'password'); + // Clear cache to read from disk + ParseUser._clearCache(); + + expect(u.id).toBe('uid2'); + expect(u.getSessionToken()).toBe('123abc'); + expect(u.isCurrent()).toBe(true); + expect(u.authenticated()).toBe(true); + expect(ParseUser.current().id).toBe('uid2'); + + const path = Storage.generatePath('currentUser'); + const userStorage = Storage.getItem(path); + expect(userStorage).toBe(ENCRYPTED_DATA); + CoreManager.set('ENCRYPTED_USER', false); + CoreManager.set('ENCRYPTED_KEY', null); + Storage._clear(); + }); + it('can static signup a user with installationId', async () => { ParseUser.disableUnsafeCurrentUser(); ParseUser._clearCache(); @@ -1018,7 +1128,6 @@ describe('ParseUser', () => { request(method, path, body, options) { expect(method).toBe('POST'); expect(path).toBe('users'); - console.log(options); expect(options.installationId).toBe(installationId); return Promise.resolve({ objectId: 'uid3', @@ -1043,7 +1152,6 @@ describe('ParseUser', () => { request(method, path, body, options) { expect(method).toBe('POST'); expect(path).toBe('users'); - console.log(options); expect(options.installationId).toBe(installationId); return Promise.resolve({ objectId: 'uid3',