Browse Source

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 <balub997@gmail.com>
Mir Arif Hasan 7 months ago
parent
commit
67f0324521

+ 3 - 0
.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"

+ 2 - 0
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;

+ 7 - 6
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 {

+ 69 - 13
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<string, any> = {};
     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<boolean> {
-  const prisma = new PrismaService();
   try {
     const propsRemainingToInsert = await getMissingInfraConfigEntries();
 

+ 23 - 0
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(),

+ 55 - 4
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 <InfraConfig>{
       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<string, string> = {};
     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,
+            },
           });
         }
       });

+ 3 - 3
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,

+ 51 - 0
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();
+}