From 67f0324521bdeb8b6ee0f4f97a9c51f8b4738681 Mon Sep 17 00:00:00 2001 From: Mir Arif Hasan Date: Wed, 14 Aug 2024 13:34:12 +0600 Subject: [PATCH] HSB-473 feat: encrypt sensitive data before storing in db (#4212) * feat: encryption added on onMuduleInit * feat: encryption changes added on sh admin mutations and query * chore: fetch minimum column from DB * feat: data encryption added on account table * test: infra config test case update * chore: env example modified * chore: update variable name * chore: refactor the code * feat: client-ids made encrypted * chore: encrypted auth client id's --------- Co-authored-by: Balu Babu --- .env.example | 3 + .../migration.sql | 2 + .../hoppscotch-backend/prisma/schema.prisma | 13 +-- .../src/infra-config/helper.ts | 82 ++++++++++++++++--- .../infra-config/infra-config.service.spec.ts | 23 ++++++ .../src/infra-config/infra-config.service.ts | 59 ++++++++++++- .../src/user/user.service.ts | 6 +- packages/hoppscotch-backend/src/utils.ts | 51 ++++++++++++ 8 files changed, 213 insertions(+), 26 deletions(-) create mode 100644 packages/hoppscotch-backend/prisma/migrations/20240725043411_infra_config_encryption/migration.sql diff --git a/.env.example b/.env.example index 28feaf08b..d5521cc7d 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,9 @@ SESSION_SECRET='add some secret here' # Note: Some auth providers may not support http requests ALLOW_SECURE_COOKIES=true +# Sensitive Data Encryption Key while storing in Database (32 character) +DATA_ENCRYPTION_KEY="data encryption key with 32 char" + # Hoppscotch App Domain Config REDIRECT_URL="http://localhost:3000" WHITELISTED_ORIGINS="http://localhost:3170,http://localhost:3000,http://localhost:3100" diff --git a/packages/hoppscotch-backend/prisma/migrations/20240725043411_infra_config_encryption/migration.sql b/packages/hoppscotch-backend/prisma/migrations/20240725043411_infra_config_encryption/migration.sql new file mode 100644 index 000000000..71cf28a01 --- /dev/null +++ b/packages/hoppscotch-backend/prisma/migrations/20240725043411_infra_config_encryption/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "InfraConfig" ADD COLUMN "isEncrypted" BOOLEAN NOT NULL DEFAULT false; diff --git a/packages/hoppscotch-backend/prisma/schema.prisma b/packages/hoppscotch-backend/prisma/schema.prisma index b0731c957..9245e94de 100644 --- a/packages/hoppscotch-backend/prisma/schema.prisma +++ b/packages/hoppscotch-backend/prisma/schema.prisma @@ -214,12 +214,13 @@ enum TeamMemberRole { } model InfraConfig { - id String @id @default(cuid()) - name String @unique - value String? - active Boolean @default(true) // Use case: Let's say, Admin wants to disable Google SSO, but doesn't want to delete the config - createdOn DateTime @default(now()) @db.Timestamp(3) - updatedOn DateTime @updatedAt @db.Timestamp(3) + id String @id @default(cuid()) + name String @unique + value String? + isEncrypted Boolean @default(false) // Use case: Let's say, Admin wants to store a Secret Key, but doesn't want to store it in plain text in `value` column + active Boolean @default(true) // Use case: Let's say, Admin wants to disable Google SSO, but doesn't want to delete the config + createdOn DateTime @default(now()) @db.Timestamp(3) + updatedOn DateTime @updatedAt @db.Timestamp(3) } model PersonalAccessToken { diff --git a/packages/hoppscotch-backend/src/infra-config/helper.ts b/packages/hoppscotch-backend/src/infra-config/helper.ts index f5909873d..990fa1f20 100644 --- a/packages/hoppscotch-backend/src/infra-config/helper.ts +++ b/packages/hoppscotch-backend/src/infra-config/helper.ts @@ -5,7 +5,7 @@ import { } from 'src/errors'; import { PrismaService } from 'src/prisma/prisma.service'; import { InfraConfigEnum } from 'src/types/InfraConfig'; -import { throwErr } from 'src/utils'; +import { decrypt, encrypt, throwErr } from 'src/utils'; import { randomBytes } from 'crypto'; export enum ServiceStatus { @@ -60,7 +60,11 @@ export async function loadInfraConfiguration() { let environmentObject: Record = {}; infraConfigs.forEach((infraConfig) => { - environmentObject[infraConfig.name] = infraConfig.value; + if (infraConfig.isEncrypted) { + environmentObject[infraConfig.name] = decrypt(infraConfig.value); + } else { + environmentObject[infraConfig.name] = infraConfig.value; + } }); return { INFRA: environmentObject }; @@ -76,119 +80,150 @@ export async function loadInfraConfiguration() { * @returns Array of default infra configs */ export async function getDefaultInfraConfigs(): Promise< - { name: InfraConfigEnum; value: string }[] + { name: InfraConfigEnum; value: string; isEncrypted: boolean }[] > { const prisma = new PrismaService(); // Prepare rows for 'infra_config' table with default values (from .env) for each 'name' - const infraConfigDefaultObjs: { name: InfraConfigEnum; value: string }[] = [ + const infraConfigDefaultObjs: { + name: InfraConfigEnum; + value: string; + isEncrypted: boolean; + }[] = [ { name: InfraConfigEnum.MAILER_SMTP_ENABLE, value: process.env.MAILER_SMTP_ENABLE ?? 'true', + isEncrypted: false, }, { name: InfraConfigEnum.MAILER_USE_CUSTOM_CONFIGS, value: process.env.MAILER_USE_CUSTOM_CONFIGS ?? 'false', + isEncrypted: false, }, { name: InfraConfigEnum.MAILER_SMTP_URL, - value: process.env.MAILER_SMTP_URL, + value: encrypt(process.env.MAILER_SMTP_URL), + isEncrypted: true, }, { name: InfraConfigEnum.MAILER_ADDRESS_FROM, value: process.env.MAILER_ADDRESS_FROM, + isEncrypted: false, }, { name: InfraConfigEnum.MAILER_SMTP_HOST, value: process.env.MAILER_SMTP_HOST, + isEncrypted: false, }, { name: InfraConfigEnum.MAILER_SMTP_PORT, value: process.env.MAILER_SMTP_PORT, + isEncrypted: false, }, { name: InfraConfigEnum.MAILER_SMTP_SECURE, value: process.env.MAILER_SMTP_SECURE, + isEncrypted: false, }, { name: InfraConfigEnum.MAILER_SMTP_USER, value: process.env.MAILER_SMTP_USER, + isEncrypted: false, }, { name: InfraConfigEnum.MAILER_SMTP_PASSWORD, - value: process.env.MAILER_SMTP_PASSWORD, + value: encrypt(process.env.MAILER_SMTP_PASSWORD), + isEncrypted: true, }, { name: InfraConfigEnum.MAILER_TLS_REJECT_UNAUTHORIZED, value: process.env.MAILER_TLS_REJECT_UNAUTHORIZED, + isEncrypted: false, }, { name: InfraConfigEnum.GOOGLE_CLIENT_ID, - value: process.env.GOOGLE_CLIENT_ID, + value: encrypt(process.env.GOOGLE_CLIENT_ID), + isEncrypted: true, }, { name: InfraConfigEnum.GOOGLE_CLIENT_SECRET, - value: process.env.GOOGLE_CLIENT_SECRET, + value: encrypt(process.env.GOOGLE_CLIENT_SECRET), + isEncrypted: true, }, { name: InfraConfigEnum.GOOGLE_CALLBACK_URL, value: process.env.GOOGLE_CALLBACK_URL, + isEncrypted: false, }, { name: InfraConfigEnum.GOOGLE_SCOPE, value: process.env.GOOGLE_SCOPE, + isEncrypted: false, }, { name: InfraConfigEnum.GITHUB_CLIENT_ID, - value: process.env.GITHUB_CLIENT_ID, + value: encrypt(process.env.GITHUB_CLIENT_ID), + isEncrypted: true, }, { name: InfraConfigEnum.GITHUB_CLIENT_SECRET, - value: process.env.GITHUB_CLIENT_SECRET, + value: encrypt(process.env.GITHUB_CLIENT_SECRET), + isEncrypted: true, }, { name: InfraConfigEnum.GITHUB_CALLBACK_URL, value: process.env.GITHUB_CALLBACK_URL, + isEncrypted: false, }, { name: InfraConfigEnum.GITHUB_SCOPE, value: process.env.GITHUB_SCOPE, + isEncrypted: false, }, { name: InfraConfigEnum.MICROSOFT_CLIENT_ID, - value: process.env.MICROSOFT_CLIENT_ID, + value: encrypt(process.env.MICROSOFT_CLIENT_ID), + isEncrypted: true, }, { name: InfraConfigEnum.MICROSOFT_CLIENT_SECRET, - value: process.env.MICROSOFT_CLIENT_SECRET, + value: encrypt(process.env.MICROSOFT_CLIENT_SECRET), + isEncrypted: true, }, { name: InfraConfigEnum.MICROSOFT_CALLBACK_URL, value: process.env.MICROSOFT_CALLBACK_URL, + isEncrypted: false, }, { name: InfraConfigEnum.MICROSOFT_SCOPE, value: process.env.MICROSOFT_SCOPE, + isEncrypted: false, }, { name: InfraConfigEnum.MICROSOFT_TENANT, value: process.env.MICROSOFT_TENANT, + isEncrypted: false, }, { name: InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS, value: getConfiguredSSOProviders(), + isEncrypted: false, }, { name: InfraConfigEnum.ALLOW_ANALYTICS_COLLECTION, value: false.toString(), + isEncrypted: false, }, { name: InfraConfigEnum.ANALYTICS_USER_ID, value: generateAnalyticsUserId(), + isEncrypted: false, }, { name: InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP, value: (await prisma.infraConfig.count()) === 0 ? 'true' : 'false', + isEncrypted: false, }, ]; @@ -214,12 +249,33 @@ export async function getMissingInfraConfigEntries() { return missingEntries; } +/** + * Get the encryption required entries in the 'infra_config' table + * @returns Array of InfraConfig + */ +export async function getEncryptionRequiredInfraConfigEntries() { + const prisma = new PrismaService(); + const [dbInfraConfigs, infraConfigDefaultObjs] = await Promise.all([ + prisma.infraConfig.findMany(), + getDefaultInfraConfigs(), + ]); + + const requiredEncryption = dbInfraConfigs.filter((dbConfig) => { + const defaultConfig = infraConfigDefaultObjs.find( + (config) => config.name === dbConfig.name, + ); + if (!defaultConfig) return false; + return defaultConfig.isEncrypted !== dbConfig.isEncrypted; + }); + + return requiredEncryption; +} + /** * Verify if 'infra_config' table is loaded with all entries * @returns boolean */ export async function isInfraConfigTablePopulated(): Promise { - const prisma = new PrismaService(); try { const propsRemainingToInsert = await getMissingInfraConfigEntries(); diff --git a/packages/hoppscotch-backend/src/infra-config/infra-config.service.spec.ts b/packages/hoppscotch-backend/src/infra-config/infra-config.service.spec.ts index 707294231..477b86837 100644 --- a/packages/hoppscotch-backend/src/infra-config/infra-config.service.spec.ts +++ b/packages/hoppscotch-backend/src/infra-config/infra-config.service.spec.ts @@ -28,6 +28,7 @@ const dbInfraConfigs: dbInfraConfig[] = [ id: '3', name: InfraConfigEnum.GOOGLE_CLIENT_ID, value: 'abcdefghijkl', + isEncrypted: false, active: true, createdOn: INITIALIZED_DATE_CONST, updatedOn: INITIALIZED_DATE_CONST, @@ -36,6 +37,7 @@ const dbInfraConfigs: dbInfraConfig[] = [ id: '4', name: InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS, value: 'google', + isEncrypted: false, active: true, createdOn: INITIALIZED_DATE_CONST, updatedOn: INITIALIZED_DATE_CONST, @@ -62,10 +64,15 @@ describe('InfraConfigService', () => { const name = InfraConfigEnum.GOOGLE_CLIENT_ID; const value = 'true'; + // @ts-ignore + mockPrisma.infraConfig.findUnique.mockResolvedValueOnce({ + isEncrypted: false, + }); mockPrisma.infraConfig.update.mockResolvedValueOnce({ id: '', name, value, + isEncrypted: false, active: true, createdOn: new Date(), updatedOn: new Date(), @@ -82,10 +89,15 @@ describe('InfraConfigService', () => { const name = InfraConfigEnum.GOOGLE_CLIENT_ID; const value = 'true'; + // @ts-ignore + mockPrisma.infraConfig.findUnique.mockResolvedValueOnce({ + isEncrypted: false, + }); mockPrisma.infraConfig.update.mockResolvedValueOnce({ id: '', name, value, + isEncrypted: false, active: true, createdOn: new Date(), updatedOn: new Date(), @@ -102,10 +114,15 @@ describe('InfraConfigService', () => { const name = InfraConfigEnum.GOOGLE_CLIENT_ID; const value = 'true'; + // @ts-ignore + mockPrisma.infraConfig.findUnique.mockResolvedValueOnce({ + isEncrypted: false, + }); mockPrisma.infraConfig.update.mockResolvedValueOnce({ id: '', name, value, + isEncrypted: false, active: true, createdOn: new Date(), updatedOn: new Date(), @@ -120,6 +137,11 @@ describe('InfraConfigService', () => { const name = InfraConfigEnum.GOOGLE_CLIENT_ID; const value = 'true'; + // @ts-ignore + mockPrisma.infraConfig.findUnique.mockResolvedValueOnce({ + isEncrypted: false, + }); + jest.spyOn(helper, 'stopApp').mockReturnValueOnce(); await infraConfigService.update(name, value); @@ -151,6 +173,7 @@ describe('InfraConfigService', () => { id: '', name, value, + isEncrypted: false, active: true, createdOn: new Date(), updatedOn: new Date(), diff --git a/packages/hoppscotch-backend/src/infra-config/infra-config.service.ts b/packages/hoppscotch-backend/src/infra-config/infra-config.service.ts index 168f37546..b636f13a6 100644 --- a/packages/hoppscotch-backend/src/infra-config/infra-config.service.ts +++ b/packages/hoppscotch-backend/src/infra-config/infra-config.service.ts @@ -15,6 +15,8 @@ import { INFRA_CONFIG_OPERATION_NOT_ALLOWED, } from 'src/errors'; import { + decrypt, + encrypt, throwErr, validateSMTPEmail, validateSMTPUrl, @@ -24,6 +26,7 @@ import { ConfigService } from '@nestjs/config'; import { ServiceStatus, getDefaultInfraConfigs, + getEncryptionRequiredInfraConfigEntries, getMissingInfraConfigEntries, stopApp, } from './helper'; @@ -62,10 +65,30 @@ export class InfraConfigService implements OnModuleInit { */ async initializeInfraConfigTable() { try { + // Adding missing InfraConfigs to the database (with encrypted values) const propsToInsert = await getMissingInfraConfigEntries(); if (propsToInsert.length > 0) { await this.prisma.infraConfig.createMany({ data: propsToInsert }); + } + + // Encrypting previous InfraConfigs that are required to be encrypted + const encryptionRequiredEntries = + await getEncryptionRequiredInfraConfigEntries(); + + if (encryptionRequiredEntries.length > 0) { + const dbOperations = encryptionRequiredEntries.map((dbConfig) => { + return this.prisma.infraConfig.update({ + where: { name: dbConfig.name }, + data: { value: encrypt(dbConfig.value), isEncrypted: true }, + }); + }); + + await Promise.allSettled(dbOperations); + } + + // Restart the app if needed + if (propsToInsert.length > 0 || encryptionRequiredEntries.length > 0) { stopApp(); } } catch (error) { @@ -76,6 +99,7 @@ export class InfraConfigService implements OnModuleInit { // Prisma error code for 'Table does not exist' throwErr(DATABASE_TABLE_NOT_EXIST); } else { + console.log(error); throwErr(error); } } @@ -87,9 +111,13 @@ export class InfraConfigService implements OnModuleInit { * @returns InfraConfig model */ cast(dbInfraConfig: DBInfraConfig) { + const plainValue = dbInfraConfig.isEncrypted + ? decrypt(dbInfraConfig.value) + : dbInfraConfig.value; + return { name: dbInfraConfig.name, - value: dbInfraConfig.value ?? '', + value: plainValue ?? '', }; } @@ -99,10 +127,16 @@ export class InfraConfigService implements OnModuleInit { */ async getInfraConfigsMap() { const infraConfigs = await this.prisma.infraConfig.findMany(); + const infraConfigMap: Record = {}; infraConfigs.forEach((config) => { - infraConfigMap[config.name] = config.value; + if (config.isEncrypted) { + infraConfigMap[config.name] = decrypt(config.value); + } else { + infraConfigMap[config.name] = config.value; + } }); + return infraConfigMap; } @@ -118,9 +152,14 @@ export class InfraConfigService implements OnModuleInit { if (E.isLeft(isValidate)) return E.left(isValidate.left); try { + const { isEncrypted } = await this.prisma.infraConfig.findUnique({ + where: { name }, + select: { isEncrypted: true }, + }); + const infraConfig = await this.prisma.infraConfig.update({ where: { name }, - data: { value }, + data: { value: isEncrypted ? encrypt(value) : value }, }); if (restartEnabled) stopApp(); @@ -146,11 +185,23 @@ export class InfraConfigService implements OnModuleInit { if (E.isLeft(isValidate)) return E.left(isValidate.left); try { + const dbInfraConfig = await this.prisma.infraConfig.findMany({ + select: { name: true, isEncrypted: true }, + }); + await this.prisma.$transaction(async (tx) => { for (let i = 0; i < infraConfigs.length; i++) { + const isEncrypted = dbInfraConfig.find( + (p) => p.name === infraConfigs[i].name, + )?.isEncrypted; + await tx.infraConfig.update({ where: { name: infraConfigs[i].name }, - data: { value: infraConfigs[i].value }, + data: { + value: isEncrypted + ? encrypt(infraConfigs[i].value) + : infraConfigs[i].value, + }, }); } }); diff --git a/packages/hoppscotch-backend/src/user/user.service.ts b/packages/hoppscotch-backend/src/user/user.service.ts index f28b4167e..9ccbdc27d 100644 --- a/packages/hoppscotch-backend/src/user/user.service.ts +++ b/packages/hoppscotch-backend/src/user/user.service.ts @@ -16,7 +16,7 @@ import { import { SessionType, User } from './user.model'; import { USER_UPDATE_FAILED } from 'src/errors'; import { PubSubService } from 'src/pubsub/pubsub.service'; -import { stringToJson, taskEitherValidateArraySeq } from 'src/utils'; +import { encrypt, stringToJson, taskEitherValidateArraySeq } from 'src/utils'; import { UserDataHandler } from './user.data.handler'; import { User as DbUser } from '@prisma/client'; import { OffsetPaginationArgs } from 'src/types/input-types.args'; @@ -208,8 +208,8 @@ export class UserService { data: { provider: profile.provider, providerAccountId: profile.id, - providerRefreshToken: refreshToken ? refreshToken : null, - providerAccessToken: accessToken ? accessToken : null, + providerRefreshToken: refreshToken ? encrypt(refreshToken) : null, + providerAccessToken: accessToken ? encrypt(accessToken) : null, user: { connect: { uid: user.uid, diff --git a/packages/hoppscotch-backend/src/utils.ts b/packages/hoppscotch-backend/src/utils.ts index 5be926174..8bc078245 100644 --- a/packages/hoppscotch-backend/src/utils.ts +++ b/packages/hoppscotch-backend/src/utils.ts @@ -17,6 +17,7 @@ import { } from './errors'; import { TeamMemberRole } from './team/team.model'; import { RESTError } from './types/RESTError'; +import * as crypto from 'crypto'; /** * A workaround to throw an exception in an expression. @@ -316,3 +317,53 @@ export function transformCollectionData( ? collectionData : JSON.stringify(collectionData); } + +// Encrypt and Decrypt functions. InfraConfig and Account table uses these functions to encrypt and decrypt the data. +const ENCRYPTION_ALGORITHM = 'aes-256-cbc'; + +/** + * Encrypts a text using a key + * @param text The text to encrypt + * @param key The key to use for encryption + * @returns The encrypted text + */ +export function encrypt(text: string, key = process.env.DATA_ENCRYPTION_KEY) { + if (text === null || text === undefined) return text; + + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv( + ENCRYPTION_ALGORITHM, + Buffer.from(key), + iv, + ); + let encrypted = cipher.update(text); + encrypted = Buffer.concat([encrypted, cipher.final()]); + return iv.toString('hex') + ':' + encrypted.toString('hex'); +} + +/** + * Decrypts a text using a key + * @param text The text to decrypt + * @param key The key to use for decryption + * @returns The decrypted text + */ +export function decrypt( + encryptedData: string, + key = process.env.DATA_ENCRYPTION_KEY, +) { + if (encryptedData === null || encryptedData === undefined) { + return encryptedData; + } + + const textParts = encryptedData.split(':'); + const iv = Buffer.from(textParts.shift(), 'hex'); + const encryptedText = Buffer.from(textParts.join(':'), 'hex'); + const decipher = crypto.createDecipheriv( + ENCRYPTION_ALGORITHM, + Buffer.from(key), + iv, + ); + let decrypted = decipher.update(encryptedText); + decrypted = Buffer.concat([decrypted, decipher.final()]); + return decrypted.toString(); +}