diff --git a/DOCKER_README.md b/DOCKER_README.md index cfe4e7f6c1..ea2e3a61d5 100644 --- a/DOCKER_README.md +++ b/DOCKER_README.md @@ -17,9 +17,10 @@ These commands will build the image and then start the container. Redis Insight Redis Insight supports several configuration values that can be supplied via container environment variables. The following may be provided: -| Variable | Purpose | Default | Additional Info | -| ---------|---------|-----------------|---------| -| RI_APP_PORT | The port the app listens on | 5000 | See [Express Documentation](https://expressjs.com/en/api.html#app.listen) | -| RI_APP_HOST | The host the app listens on | 0.0.0.0 | See [Express Documentation](https://expressjs.com/en/api.html#app.listen) | -| RI_SERVER_TLS_KEY | Private key for HTTPS | | Private key in [PEM format](https://www.ssl.com/guide/pem-der-crt-and-cer-x-509-encodings-and-conversions/#ftoc-heading-3). May be a path to a file or a string in PEM format. | -| RI_SERVER_TLS_CERT | Certificate for supplied private key | | Public certificate in [PEM format](https://www.ssl.com/guide/pem-der-crt-and-cer-x-509-encodings-and-conversions/#ftoc-heading-3) | +| Variable | Purpose | Default | Additional Info | +| ---------|---------|-----------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| RI_APP_PORT | The port the app listens on | 5000 | See [Express Documentation](https://expressjs.com/en/api.html#app.listen) | +| RI_APP_HOST | The host the app listens on | 0.0.0.0 | See [Express Documentation](https://expressjs.com/en/api.html#app.listen) | +| RI_SERVER_TLS_KEY | Private key for HTTPS | | Private key in [PEM format](https://www.ssl.com/guide/pem-der-crt-and-cer-x-509-encodings-and-conversions/#ftoc-heading-3). May be a path to a file or a string in PEM format. | +| RI_SERVER_TLS_CERT | Certificate for supplied private key | | Public certificate in [PEM format](https://www.ssl.com/guide/pem-der-crt-and-cer-x-509-encodings-and-conversions/#ftoc-heading-3) | +| RI_ENCRYPTION_KEY | Key to encrypt data with | | Redisinsight stores some data such as connection details locally (using [sqlite3](https://github.com/TryGhost/node-sqlite3)). It might be usefull to store sensitive data such as passwords, or private keys encrypted. For this case RedisInsight supports encryption with provided key.
Note: The Key must be the same for the same RedisInsight instance to be able to decrypt exising data. If for some reason the key was changed, you will have to enter the credentials again to connect to the Redis database. | diff --git a/redisinsight/api/config/default.ts b/redisinsight/api/config/default.ts index d12a8675b7..b436823e9c 100644 --- a/redisinsight/api/config/default.ts +++ b/redisinsight/api/config/default.ts @@ -55,6 +55,7 @@ export default { pluginsAssetsUri: '/static/resources/plugins', base: process.env.RI_BASE || '/', secretStoragePassword: process.env.SECRET_STORAGE_PASSWORD, + encryptionKey: process.env.RI_ENCRYPTION_KEY, tlsCert: process.env.RI_SERVER_TLS_CERT, tlsKey: process.env.RI_SERVER_TLS_KEY, staticContent: !!process.env.SERVER_STATIC_CONTENT || false, diff --git a/redisinsight/api/src/__mocks__/encryption.ts b/redisinsight/api/src/__mocks__/encryption.ts index 33d6d4666c..27dbed3e72 100644 --- a/redisinsight/api/src/__mocks__/encryption.ts +++ b/redisinsight/api/src/__mocks__/encryption.ts @@ -2,6 +2,7 @@ import { EncryptionStrategy } from 'src/modules/encryption/models'; export const mockDataToEncrypt = 'stringtoencrypt'; export const mockKeytarPassword = 'somepassword'; +export const mockEncryptionKey = 'somepassword'; export const mockEncryptionStrategy = EncryptionStrategy.KEYTAR; @@ -10,6 +11,13 @@ export const mockEncryptResult = { encryption: mockEncryptionStrategy, }; +export const mockKeyEncryptionStrategy = EncryptionStrategy.KEY; + +export const mockKeyEncryptResult = { + data: '4a558dfef5c1abbdf745232614194ee9', + encryption: mockKeyEncryptionStrategy, +}; + export const mockEncryptionService = jest.fn(() => ({ getAvailableEncryptionStrategies: jest.fn(), encrypt: jest.fn(), @@ -22,6 +30,12 @@ export const mockEncryptionStrategyInstance = jest.fn(() => ({ decrypt: jest.fn(), })); +export const mockKeyEncryptionStrategyInstance = jest.fn(() => ({ + isAvailable: jest.fn(), + encrypt: jest.fn(), + decrypt: jest.fn(), +})); + export const mockKeytarModule = { getPassword: jest.fn(), setPassword: jest.fn(), diff --git a/redisinsight/api/src/modules/encryption/encryption.module.ts b/redisinsight/api/src/modules/encryption/encryption.module.ts index 97467e9ebd..e7c88cc375 100644 --- a/redisinsight/api/src/modules/encryption/encryption.module.ts +++ b/redisinsight/api/src/modules/encryption/encryption.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { PlainEncryptionStrategy } from 'src/modules/encryption/strategies/plain-encryption.strategy'; import { KeytarEncryptionStrategy } from 'src/modules/encryption/strategies/keytar-encryption.strategy'; import { EncryptionService } from 'src/modules/encryption/encryption.service'; +import { KeyEncryptionStrategy } from 'src/modules/encryption/strategies/key-encryption.strategy'; @Module({}) export class EncryptionModule { @@ -11,6 +12,7 @@ export class EncryptionModule { providers: [ PlainEncryptionStrategy, KeytarEncryptionStrategy, + KeyEncryptionStrategy, EncryptionService, ], exports: [ diff --git a/redisinsight/api/src/modules/encryption/encryption.service.spec.ts b/redisinsight/api/src/modules/encryption/encryption.service.spec.ts index 53d4763635..ee3c26ae1b 100644 --- a/redisinsight/api/src/modules/encryption/encryption.service.spec.ts +++ b/redisinsight/api/src/modules/encryption/encryption.service.spec.ts @@ -1,8 +1,12 @@ import { Test, TestingModule } from '@nestjs/testing'; import { - mockAppSettings, mockAppSettingsInitial, mockAppSettingsWithoutPermissions, + mockAppSettings, + mockAppSettingsInitial, + mockAppSettingsWithoutPermissions, mockEncryptionStrategyInstance, mockEncryptResult, + mockKeyEncryptionStrategyInstance, + mockKeyEncryptResult, mockSettingsService, MockType, } from 'src/__mocks__'; @@ -12,11 +16,13 @@ import { KeytarEncryptionStrategy } from 'src/modules/encryption/strategies/keyt import { EncryptionStrategy } from 'src/modules/encryption/models'; import { UnsupportedEncryptionStrategyException } from 'src/modules/encryption/exceptions'; import { SettingsService } from 'src/modules/settings/settings.service'; +import { KeyEncryptionStrategy } from 'src/modules/encryption/strategies/key-encryption.strategy'; describe('EncryptionService', () => { let service: EncryptionService; let plainEncryptionStrategy: MockType; let keytarEncryptionStrategy: MockType; + let keyEncryptionStrategy: MockType; let settingsService: MockType; beforeEach(async () => { @@ -33,6 +39,10 @@ describe('EncryptionService', () => { provide: KeytarEncryptionStrategy, useFactory: mockEncryptionStrategyInstance, }, + { + provide: KeyEncryptionStrategy, + useFactory: mockKeyEncryptionStrategyInstance, + }, { provide: SettingsService, useFactory: mockSettingsService, @@ -43,22 +53,43 @@ describe('EncryptionService', () => { service = module.get(EncryptionService); plainEncryptionStrategy = module.get(PlainEncryptionStrategy); keytarEncryptionStrategy = module.get(KeytarEncryptionStrategy); + keyEncryptionStrategy = module.get(KeyEncryptionStrategy); settingsService = module.get(SettingsService); settingsService.getAppSettings.mockResolvedValue(mockAppSettings); }); describe('getAvailableEncryptionStrategies', () => { - it('Should return list 2 strategies available', async () => { + it('Should return list 2 strategies available (KEYTAR and PLAIN)', async () => { keytarEncryptionStrategy.isAvailable.mockResolvedValueOnce(true); + keyEncryptionStrategy.isAvailable.mockResolvedValueOnce(false); expect(await service.getAvailableEncryptionStrategies()).toEqual([ EncryptionStrategy.PLAIN, EncryptionStrategy.KEYTAR, ]); }); + it('Should return list 2 strategies available (KEY and PLAIN)', async () => { + keytarEncryptionStrategy.isAvailable.mockResolvedValueOnce(false); + keyEncryptionStrategy.isAvailable.mockResolvedValueOnce(true); + + expect(await service.getAvailableEncryptionStrategies()).toEqual([ + EncryptionStrategy.PLAIN, + EncryptionStrategy.KEY, + ]); + }); + it('Should return list 2 strategies available (KEY and PLAIN) even when KEYTAR available', async () => { + keytarEncryptionStrategy.isAvailable.mockResolvedValueOnce(true); + keyEncryptionStrategy.isAvailable.mockResolvedValueOnce(true); + + expect(await service.getAvailableEncryptionStrategies()).toEqual([ + EncryptionStrategy.PLAIN, + EncryptionStrategy.KEY, + ]); + }); it('Should return list with one strategy available', async () => { keytarEncryptionStrategy.isAvailable.mockResolvedValueOnce(false); + keyEncryptionStrategy.isAvailable.mockResolvedValueOnce(false); expect(await service.getAvailableEncryptionStrategies()).toEqual([ EncryptionStrategy.PLAIN, @@ -70,7 +101,15 @@ describe('EncryptionService', () => { it('Should return KEYTAR strategy based on app agreements', async () => { expect(await service.getEncryptionStrategy()).toEqual(keytarEncryptionStrategy); }); - it('Should return PLAIN strategy based on app agreements', async () => { + it('Should return KEY strategy based on app agreements even when KEYTAR available', async () => { + keytarEncryptionStrategy.isAvailable.mockResolvedValueOnce(true); + keyEncryptionStrategy.isAvailable.mockResolvedValueOnce(true); + + expect(await service.getEncryptionStrategy()).toEqual(keyEncryptionStrategy); + }); + it('Should return PLAIN strategy based on app agreements even when KEY available', async () => { + keyEncryptionStrategy.isAvailable.mockResolvedValueOnce(true); + settingsService.getAppSettings.mockResolvedValueOnce(mockAppSettingsWithoutPermissions); expect(await service.getEncryptionStrategy()).toEqual(plainEncryptionStrategy); @@ -83,19 +122,37 @@ describe('EncryptionService', () => { }); describe('encrypt', () => { - it('Should encrypt data and return proper response', async () => { + it('Should encrypt data and return proper response (KEYTAR)', async () => { + keyEncryptionStrategy.isAvailable.mockResolvedValueOnce(false); + keytarEncryptionStrategy.encrypt.mockResolvedValueOnce(mockEncryptResult); expect(await service.encrypt('string')).toEqual(mockEncryptResult); }); + it('Should encrypt data and return proper response (KEY)', async () => { + keyEncryptionStrategy.isAvailable.mockResolvedValueOnce(true); + + keyEncryptionStrategy.encrypt.mockResolvedValueOnce(mockKeyEncryptResult); + + expect(await service.encrypt('string')).toEqual(mockKeyEncryptResult); + }); }); describe('decrypt', () => { - it('Should return decrypted string', async () => { + it('Should return decrypted string (KEYTAR)', async () => { + keyEncryptionStrategy.isAvailable.mockResolvedValueOnce(false); + keytarEncryptionStrategy.decrypt.mockResolvedValueOnce(mockEncryptResult.data); expect(await service.decrypt('string', EncryptionStrategy.KEYTAR)).toEqual(mockEncryptResult.data); }); + it('Should return decrypted string (KEY)', async () => { + keyEncryptionStrategy.isAvailable.mockResolvedValueOnce(true); + + keyEncryptionStrategy.decrypt.mockResolvedValueOnce(mockKeyEncryptResult.data); + + expect(await service.decrypt('string', EncryptionStrategy.KEY)).toEqual(mockKeyEncryptResult.data); + }); it('Should return null when no data passed', async () => { expect(await service.decrypt(null, EncryptionStrategy.KEYTAR)).toEqual(null); }); diff --git a/redisinsight/api/src/modules/encryption/encryption.service.ts b/redisinsight/api/src/modules/encryption/encryption.service.ts index 17ff519acc..7a49b64f6a 100644 --- a/redisinsight/api/src/modules/encryption/encryption.service.ts +++ b/redisinsight/api/src/modules/encryption/encryption.service.ts @@ -7,6 +7,7 @@ import { UnsupportedEncryptionStrategyException, } from 'src/modules/encryption/exceptions'; import { SettingsService } from 'src/modules/settings/settings.service'; +import { KeyEncryptionStrategy } from 'src/modules/encryption/strategies/key-encryption.strategy'; @Injectable() export class EncryptionService { @@ -14,6 +15,7 @@ export class EncryptionService { private readonly settingsService: SettingsService, private readonly keytarEncryptionStrategy: KeytarEncryptionStrategy, private readonly plainEncryptionStrategy: PlainEncryptionStrategy, + private readonly keyEncryptionStrategy: KeyEncryptionStrategy, ) {} /** @@ -25,7 +27,9 @@ export class EncryptionService { EncryptionStrategy.PLAIN, ]; - if (await this.keytarEncryptionStrategy.isAvailable()) { + if (await this.keyEncryptionStrategy.isAvailable()) { + strategies.push(EncryptionStrategy.KEY); + } else if (await this.keytarEncryptionStrategy.isAvailable()) { strategies.push(EncryptionStrategy.KEYTAR); } @@ -43,6 +47,9 @@ export class EncryptionService { const settings = await this.settingsService.getAppSettings('1'); switch (settings.agreements?.encryption) { case true: + if (await this.keyEncryptionStrategy.isAvailable()) { + return this.keyEncryptionStrategy; + } return this.keytarEncryptionStrategy; case false: return this.plainEncryptionStrategy; diff --git a/redisinsight/api/src/modules/encryption/exceptions/index.ts b/redisinsight/api/src/modules/encryption/exceptions/index.ts index 15fa259cda..ac5e07eb85 100644 --- a/redisinsight/api/src/modules/encryption/exceptions/index.ts +++ b/redisinsight/api/src/modules/encryption/exceptions/index.ts @@ -1,4 +1,7 @@ export * from './encryption-service-error.exception'; +export * from './key-decryption-error.exception'; +export * from './key-encryption-error.exception'; +export * from './key-unavailable.exception'; export * from './keytar-decryption-error.exception'; export * from './keytar-encryption-error.exception'; export * from './keytar-unavailable.exception'; diff --git a/redisinsight/api/src/modules/encryption/exceptions/key-decryption-error.exception.ts b/redisinsight/api/src/modules/encryption/exceptions/key-decryption-error.exception.ts new file mode 100644 index 0000000000..0fada85c64 --- /dev/null +++ b/redisinsight/api/src/modules/encryption/exceptions/key-decryption-error.exception.ts @@ -0,0 +1,13 @@ +import { + EncryptionServiceErrorException, +} from 'src/modules/encryption/exceptions/encryption-service-error.exception'; + +export class KeyDecryptionErrorException extends EncryptionServiceErrorException { + constructor(message = 'Unable to decrypt data') { + super({ + message, + name: 'KeyDecryptionError', + statusCode: 500, + }, 500); + } +} diff --git a/redisinsight/api/src/modules/encryption/exceptions/key-encryption-error.exception.ts b/redisinsight/api/src/modules/encryption/exceptions/key-encryption-error.exception.ts new file mode 100644 index 0000000000..0d1cb70ae9 --- /dev/null +++ b/redisinsight/api/src/modules/encryption/exceptions/key-encryption-error.exception.ts @@ -0,0 +1,13 @@ +import { + EncryptionServiceErrorException, +} from 'src/modules/encryption/exceptions/encryption-service-error.exception'; + +export class KeyEncryptionErrorException extends EncryptionServiceErrorException { + constructor(message = 'Unable to encrypt data') { + super({ + message, + name: 'KeyEncryptionError', + statusCode: 500, + }, 500); + } +} diff --git a/redisinsight/api/src/modules/encryption/exceptions/key-unavailable.exception.ts b/redisinsight/api/src/modules/encryption/exceptions/key-unavailable.exception.ts new file mode 100644 index 0000000000..b97d5dd879 --- /dev/null +++ b/redisinsight/api/src/modules/encryption/exceptions/key-unavailable.exception.ts @@ -0,0 +1,13 @@ +import { + EncryptionServiceErrorException, +} from 'src/modules/encryption/exceptions/encryption-service-error.exception'; + +export class KeyUnavailableException extends EncryptionServiceErrorException { + constructor(message = 'Encryption key unavailable') { + super({ + message, + name: 'KeyUnavailable', + statusCode: 503, + }, 503); + } +} diff --git a/redisinsight/api/src/modules/encryption/models/encryption-result.ts b/redisinsight/api/src/modules/encryption/models/encryption-result.ts index 1645037f9a..7436ca78e4 100644 --- a/redisinsight/api/src/modules/encryption/models/encryption-result.ts +++ b/redisinsight/api/src/modules/encryption/models/encryption-result.ts @@ -1,6 +1,7 @@ export enum EncryptionStrategy { PLAIN = 'PLAIN', KEYTAR = 'KEYTAR', + KEY = 'KEY', } export class EncryptionResult { diff --git a/redisinsight/api/src/modules/encryption/strategies/key-encryption.strategy.spec.ts b/redisinsight/api/src/modules/encryption/strategies/key-encryption.strategy.spec.ts new file mode 100644 index 0000000000..2f5aea48ea --- /dev/null +++ b/redisinsight/api/src/modules/encryption/strategies/key-encryption.strategy.spec.ts @@ -0,0 +1,91 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { + mockDataToEncrypt, + mockEncryptionKey, + mockEncryptResult, + mockKeyEncryptResult, +} from 'src/__mocks__'; +import { + KeyDecryptionErrorException, + KeyEncryptionErrorException, + KeyUnavailableException, +} from 'src/modules/encryption/exceptions'; +import { KeyEncryptionStrategy } from 'src/modules/encryption/strategies/key-encryption.strategy'; + +describe('KeyEncryptionStrategy', () => { + let service: KeyEncryptionStrategy; + + beforeEach(async () => { + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [KeyEncryptionStrategy], + }).compile(); + + service = module.get(KeyEncryptionStrategy); + // @ts-ignore + service['key'] = mockEncryptionKey; + }); + + describe('isAvailable', () => { + it('Should return true when env specified', async () => { + expect(await service.isAvailable()).toEqual(true); + }); + + it('Should return false when env is not specified', async () => { + // @ts-ignore + service['key'] = undefined; + + expect(await service.isAvailable()).toEqual(false); + }); + }); + + describe('encrypt', () => { + it('Should encrypt data', async () => { + expect(service['cipherKey']).toEqual(undefined); + expect(await service.encrypt(mockDataToEncrypt)).toEqual(mockKeyEncryptResult); + expect(service['cipherKey']).not.toEqual(undefined); + }); + it('Should throw KeyEncryptionError when unable to encrypt', async () => { + await expect(service.encrypt(null)).rejects.toThrowError(KeyEncryptionErrorException); + }); + it('Should throw KeyUnavailable when there is no key but we are trying to encrypt', async () => { + // @ts-ignore + service['key'] = undefined; + + await expect(service.encrypt(mockDataToEncrypt)).rejects.toThrowError(KeyUnavailableException); + }); + }); + + describe('decrypt', () => { + it('Should decrypt data', async () => { + expect(service['cipherKey']).toEqual(undefined); + expect(await service.decrypt( + mockKeyEncryptResult.data, + mockKeyEncryptResult.encryption, + )).toEqual(mockDataToEncrypt); + expect(service['cipherKey']).not.toEqual(undefined); + }); + it('Should return null when encryption doesn\'t match KEY', async () => { + expect(await service.decrypt( + mockEncryptResult.data, + 'PLAIN', + )).toEqual(null); + }); + it('Should throw KeyDecryptionError when unable to decrypt', async () => { + await expect(service.decrypt( + null, + mockKeyEncryptResult.encryption, + )).rejects.toThrowError(KeyDecryptionErrorException); + }); + it('Should throw KeyUnavailable when there is no key but we are trying to decrypt', async () => { + // @ts-ignore + service['key'] = undefined; + + await expect(service.decrypt( + mockKeyEncryptResult.data, + mockKeyEncryptResult.encryption, + )).rejects.toThrowError(KeyUnavailableException); + }); + }); +}); diff --git a/redisinsight/api/src/modules/encryption/strategies/key-encryption.strategy.ts b/redisinsight/api/src/modules/encryption/strategies/key-encryption.strategy.ts new file mode 100644 index 0000000000..364a8f2c71 --- /dev/null +++ b/redisinsight/api/src/modules/encryption/strategies/key-encryption.strategy.ts @@ -0,0 +1,89 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { + createDecipheriv, createCipheriv, createHash, +} from 'crypto'; +import { EncryptionResult, EncryptionStrategy } from 'src/modules/encryption/models'; +import { IEncryptionStrategy } from 'src/modules/encryption/strategies/encryption-strategy.interface'; +import { + KeyDecryptionErrorException, + KeyEncryptionErrorException, + KeyUnavailableException, +} from 'src/modules/encryption/exceptions'; +import config, { Config } from 'src/utils/config'; + +const ALGORITHM = 'aes-256-cbc'; +const HASH_ALGORITHM = 'sha256'; +const SERVER_CONFIG = config.get('server') as Config['server']; + +@Injectable() +export class KeyEncryptionStrategy implements IEncryptionStrategy { + private logger = new Logger('KeyEncryptionStrategy'); + + private cipherKey: Buffer; + + private readonly key: string; + + constructor() { + this.key = SERVER_CONFIG.encryptionKey; + } + + /** + * Will return existing cipher stored in-memory or + * create new one using specified key and store it in-memory + */ + private async getCipherKey(): Promise { + if (!this.cipherKey) { + if (!this.key) { + throw new KeyUnavailableException(); + } + + this.cipherKey = createHash(HASH_ALGORITHM) + .update(this.key, 'utf8') + .digest(); + } + + return this.cipherKey; + } + + /** + * Checks if secret key was specified + */ + async isAvailable(): Promise { + return !!this.key; + } + + async encrypt(data: string): Promise { + const cipherKey = await this.getCipherKey(); + try { + const cipher = createCipheriv(ALGORITHM, cipherKey, Buffer.alloc(16, 0)); + let encrypted = cipher.update(data, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + + return { + encryption: EncryptionStrategy.KEY, + data: encrypted, + }; + } catch (error) { + this.logger.error('Unable to encrypt data', error); + throw new KeyEncryptionErrorException(); + } + } + + async decrypt(data: string, encryptedWith: string): Promise { + if (encryptedWith !== EncryptionStrategy.KEY) { + return null; + } + + const cipherKey = await this.getCipherKey(); + + try { + const decipher = createDecipheriv(ALGORITHM, cipherKey, Buffer.alloc(16, 0)); + let decrypted = decipher.update(data, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + return decrypted; + } catch (error) { + this.logger.error('Unable to decrypt data', error); + throw new KeyDecryptionErrorException(); + } + } +}