diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev index ec34f63d7..97bc2c6a3 100644 --- a/backend/Dockerfile.dev +++ b/backend/Dockerfile.dev @@ -1,5 +1,44 @@ FROM node:20-alpine +# ? Setup a test SoftHSM module. In production a real HSM is used. + +ARG SOFTHSM2_VERSION=2.5.0 + +ENV SOFTHSM2_VERSION=${SOFTHSM2_VERSION} \ + SOFTHSM2_SOURCES=/tmp/softhsm2 + +# install build dependencies including python3 +RUN apk --update add \ + alpine-sdk \ + autoconf \ + automake \ + git \ + libtool \ + openssl-dev \ + python3 \ + make \ + g++ + +# build and install SoftHSM2 +RUN git clone https://github.com/opendnssec/SoftHSMv2.git ${SOFTHSM2_SOURCES} +WORKDIR ${SOFTHSM2_SOURCES} + +RUN git checkout ${SOFTHSM2_VERSION} -b ${SOFTHSM2_VERSION} \ + && sh autogen.sh \ + && ./configure --prefix=/usr/local --disable-gost \ + && make \ + && make install + +WORKDIR /root +RUN rm -fr ${SOFTHSM2_SOURCES} + +# install pkcs11-tool +RUN apk --update add opensc + +RUN softhsm2-util --init-token --slot 0 --label "auth-app" --pin 1234 --so-pin 0000 + +# ? App setup + RUN apk add --no-cache bash curl && curl -1sLf \ 'https://dl.cloudsmith.io/public/infisical/infisical-cli/setup.alpine.sh' | bash \ && apk add infisical=0.8.1 && apk add --no-cache git diff --git a/backend/package-lock.json b/backend/package-lock.json index 6829ebbc8..ef9f4e0d3 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -55,6 +55,7 @@ "fastify-plugin": "^4.5.1", "google-auth-library": "^9.9.0", "googleapis": "^137.1.0", + "graphene-pk11": "^2.3.6", "handlebars": "^4.7.8", "hdb": "^0.19.10", "ioredis": "^5.3.2", @@ -13556,6 +13557,23 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, + "node_modules/graphene-pk11": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/graphene-pk11/-/graphene-pk11-2.3.6.tgz", + "integrity": "sha512-ol9Pf7XDv5UTjh1DPqtmQVZQqUheiXBzQVXQWRCLWq78+brKQB0Kum/s0NGEcsd/5NQQG8MFA2U/KNujEoC1fQ==", + "license": "MIT", + "dependencies": { + "pkcs11js": "^2.1.6", + "tslib": "^2.7.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/PeculiarVentures" + } + }, "node_modules/graphql": { "version": "16.9.0", "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.9.0.tgz", @@ -17066,6 +17084,20 @@ "node": ">= 6" } }, + "node_modules/pkcs11js": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/pkcs11js/-/pkcs11js-2.1.6.tgz", + "integrity": "sha512-+t5jxzB749q8GaEd1yNx3l98xYuaVK6WW/Vjg1Mk1Iy5bMu/A5W4O/9wZGrpOknWF6lFQSb12FXX+eSNxdriwA==", + "hasInstallScript": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/PeculiarVentures" + } + }, "node_modules/pkg-conf": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/pkg-conf/-/pkg-conf-3.1.0.tgz", @@ -19761,9 +19793,10 @@ } }, "node_modules/tslib": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", - "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==" + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.0.tgz", + "integrity": "sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA==", + "license": "0BSD" }, "node_modules/tsup": { "version": "8.0.1", diff --git a/backend/package.json b/backend/package.json index 1336478a1..0278689d3 100644 --- a/backend/package.json +++ b/backend/package.json @@ -160,6 +160,7 @@ "fastify-plugin": "^4.5.1", "google-auth-library": "^9.9.0", "googleapis": "^137.1.0", + "graphene-pk11": "^2.3.6", "handlebars": "^4.7.8", "hdb": "^0.19.10", "ioredis": "^5.3.2", diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index d79c224e5..ebe3f4289 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -44,6 +44,7 @@ import { TCmekServiceFactory } from "@app/services/cmek/cmek-service"; import { TExternalGroupOrgRoleMappingServiceFactory } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-service"; import { TExternalMigrationServiceFactory } from "@app/services/external-migration/external-migration-service"; import { TGroupProjectServiceFactory } from "@app/services/group-project/group-project-service"; +import { THsmServiceFactory } from "@app/services/hsm/hsm-service"; import { TIdentityServiceFactory } from "@app/services/identity/identity-service"; import { TIdentityAccessTokenServiceFactory } from "@app/services/identity-access-token/identity-access-token-service"; import { TIdentityAwsAuthServiceFactory } from "@app/services/identity-aws-auth/identity-aws-auth-service"; @@ -184,6 +185,7 @@ declare module "fastify" { rateLimit: TRateLimitServiceFactory; userEngagement: TUserEngagementServiceFactory; externalKms: TExternalKmsServiceFactory; + hsm: THsmServiceFactory; orgAdmin: TOrgAdminServiceFactory; slack: TSlackServiceFactory; workflowIntegration: TWorkflowIntegrationServiceFactory; diff --git a/backend/src/db/migrations/20241028134337_kms-with-hsm.ts b/backend/src/db/migrations/20241028134337_kms-with-hsm.ts new file mode 100644 index 000000000..86fc556d6 --- /dev/null +++ b/backend/src/db/migrations/20241028134337_kms-with-hsm.ts @@ -0,0 +1,23 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasIsEncryptedByHsmCol = await knex.schema.hasColumn(TableName.KmsServerRootConfig, "isEncryptedByHsm"); + const hasTimestampsCol = await knex.schema.hasColumn(TableName.KmsServerRootConfig, "createdAt"); + + await knex.schema.alterTable(TableName.KmsServerRootConfig, (t) => { + if (!hasIsEncryptedByHsmCol) t.boolean("isEncryptedByHsm").defaultTo(false).notNullable(); + if (!hasTimestampsCol) t.timestamps(true, true); + }); +} + +export async function down(knex: Knex): Promise { + const hasIsEncryptedByHsmCol = await knex.schema.hasColumn(TableName.KmsServerRootConfig, "isEncryptedByHsm"); + const hasTimestampsCol = await knex.schema.hasColumn(TableName.KmsServerRootConfig, "createdAt"); + + await knex.schema.alterTable(TableName.KmsServerRootConfig, (t) => { + if (hasIsEncryptedByHsmCol) t.dropColumn("isEncryptedByHsm"); + if (hasTimestampsCol) t.dropTimestamps(true); + }); +} diff --git a/backend/src/db/schemas/kms-root-config.ts b/backend/src/db/schemas/kms-root-config.ts index d2c0edbc5..978d108bd 100644 --- a/backend/src/db/schemas/kms-root-config.ts +++ b/backend/src/db/schemas/kms-root-config.ts @@ -11,7 +11,10 @@ import { TImmutableDBKeys } from "./models"; export const KmsRootConfigSchema = z.object({ id: z.string().uuid(), - encryptedRootKey: zodBuffer + encryptedRootKey: zodBuffer, + isEncryptedByHsm: z.boolean().default(false), + createdAt: z.date(), + updatedAt: z.date() }); export type TKmsRootConfig = z.infer; diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 638f21e5d..b289968bf 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -163,7 +163,38 @@ const envSchema = z SSL_CLIENT_CERTIFICATE_HEADER_KEY: zpStr(z.string().optional()).default("x-ssl-client-cert"), WORKFLOW_SLACK_CLIENT_ID: zpStr(z.string().optional()), WORKFLOW_SLACK_CLIENT_SECRET: zpStr(z.string().optional()), - ENABLE_MSSQL_SECRET_ROTATION_ENCRYPT: zodStrBool.default("true") + ENABLE_MSSQL_SECRET_ROTATION_ENCRYPT: zodStrBool.default("true"), + + // HSM + HSM_LIB_PATH: zpStr( + z + .string() + .optional() + .transform((val) => { + if (process.env.NODE_ENV === "development") return "/usr/local/lib/softhsm/libsofthsm2.so"; + return val; + }) + ), + HSM_PIN: zpStr( + z + .string() + .optional() + .transform((val) => { + if (process.env.NODE_ENV === "development") return "1234"; + return val; + }) + ), + HSM_KEY_LABEL: zpStr( + z + .string() + .optional() + .transform((val) => { + if (process.env.NODE_ENV === "development") return "auth-app"; + return val; + }) + ), + HSM_SLOT: z.coerce.number().optional().default(0), + HSM_MECHANISM: zpStr(z.string().optional().default("AES_GCM")) }) .transform((data) => ({ ...data, @@ -175,10 +206,18 @@ const envSchema = z isRedisConfigured: Boolean(data.REDIS_URL), isDevelopmentMode: data.NODE_ENV === "development", isProductionMode: data.NODE_ENV === "production" || IS_PACKAGED, + isSecretScanningConfigured: Boolean(data.SECRET_SCANNING_GIT_APP_ID) && Boolean(data.SECRET_SCANNING_PRIVATE_KEY) && Boolean(data.SECRET_SCANNING_WEBHOOK_SECRET), + isHsmConfigured: + Boolean(data.HSM_LIB_PATH) && + Boolean(data.HSM_PIN) && + Boolean(data.HSM_KEY_LABEL) && + Boolean(data.HSM_MECHANISM) && + data.HSM_SLOT !== undefined, + samlDefaultOrgSlug: data.DEFAULT_SAML_ORG_SLUG, SECRET_SCANNING_ORG_WHITELIST: data.SECRET_SCANNING_ORG_WHITELIST?.split(",") })); diff --git a/backend/src/main.ts b/backend/src/main.ts index f71a1fe95..f0ed3fddb 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -9,6 +9,7 @@ import { initLogger } from "./lib/logger"; import { queueServiceFactory } from "./queue"; import { main } from "./server/app"; import { bootstrapCheck } from "./server/boot-strap-check"; +import { initializePkcs11Module } from "./services/hsm/hsm-fns"; import { smtpServiceFactory } from "./services/smtp/smtp-service"; dotenv.config(); @@ -53,13 +54,17 @@ const run = async () => { const queue = queueServiceFactory(appCfg.REDIS_URL); const keyStore = keyStoreFactory(appCfg.REDIS_URL); - const server = await main({ db, auditLogDb, smtp, logger, queue, keyStore }); + const pkcs11Module = initializePkcs11Module(); + pkcs11Module.initialize(); + + const server = await main({ db, auditLogDb, hsmModule: pkcs11Module.getModule(), smtp, logger, queue, keyStore }); const bootstrap = await bootstrapCheck({ db }); // eslint-disable-next-line process.on("SIGINT", async () => { await server.close(); await db.destroy(); + pkcs11Module.finalize(); process.exit(0); }); @@ -67,6 +72,7 @@ const run = async () => { process.on("SIGTERM", async () => { await server.close(); await db.destroy(); + pkcs11Module.finalize(); process.exit(0); }); diff --git a/backend/src/server/app.ts b/backend/src/server/app.ts index b768d0db5..03f3f98f9 100644 --- a/backend/src/server/app.ts +++ b/backend/src/server/app.ts @@ -17,6 +17,7 @@ import { Logger } from "pino"; import { TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig, IS_PACKAGED } from "@app/lib/config/env"; import { TQueueServiceFactory } from "@app/queue"; +import { HsmModule } from "@app/services/hsm/hsm-fns"; import { TSmtpService } from "@app/services/smtp/smtp-service"; import { globalRateLimiterCfg } from "./config/rateLimiter"; @@ -36,10 +37,11 @@ type TMain = { logger?: Logger; queue: TQueueServiceFactory; keyStore: TKeyStoreFactory; + hsmModule: HsmModule; }; // Run the server! -export const main = async ({ db, auditLogDb, smtp, logger, queue, keyStore }: TMain) => { +export const main = async ({ db, hsmModule, auditLogDb, smtp, logger, queue, keyStore }: TMain) => { const appCfg = getConfig(); const server = fastify({ logger: appCfg.NODE_ENV === "test" ? false : logger, @@ -95,7 +97,7 @@ export const main = async ({ db, auditLogDb, smtp, logger, queue, keyStore }: TM await server.register(maintenanceMode); - await server.register(registerRoutes, { smtp, queue, db, auditLogDb, keyStore }); + await server.register(registerRoutes, { smtp, queue, db, auditLogDb, keyStore, hsmModule }); if (appCfg.isProductionMode) { await server.register(registerExternalNextjs, { diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index b2d097092..550277de1 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -1,5 +1,4 @@ import { CronJob } from "cron"; -// import { Redis } from "ioredis"; import { Knex } from "knex"; import { z } from "zod"; @@ -108,6 +107,8 @@ import { externalMigrationServiceFactory } from "@app/services/external-migratio import { groupProjectDALFactory } from "@app/services/group-project/group-project-dal"; import { groupProjectMembershipRoleDALFactory } from "@app/services/group-project/group-project-membership-role-dal"; import { groupProjectServiceFactory } from "@app/services/group-project/group-project-service"; +import { HsmModule } from "@app/services/hsm/hsm-fns"; +import { hsmServiceFactory } from "@app/services/hsm/hsm-service"; import { identityDALFactory } from "@app/services/identity/identity-dal"; import { identityMetadataDALFactory } from "@app/services/identity/identity-metadata-dal"; import { identityOrgDALFactory } from "@app/services/identity/identity-org-dal"; @@ -223,10 +224,18 @@ export const registerRoutes = async ( { auditLogDb, db, + hsmModule, smtp: smtpService, queue: queueService, keyStore - }: { auditLogDb?: Knex; db: Knex; smtp: TSmtpService; queue: TQueueServiceFactory; keyStore: TKeyStoreFactory } + }: { + auditLogDb?: Knex; + db: Knex; + hsmModule: HsmModule; + smtp: TSmtpService; + queue: TQueueServiceFactory; + keyStore: TKeyStoreFactory; + } ) => { const appCfg = getConfig(); await server.register(registerSecretScannerGhApp, { prefix: "/ss-webhook" }); @@ -352,14 +361,21 @@ export const registerRoutes = async ( projectDAL }); const licenseService = licenseServiceFactory({ permissionService, orgDAL, licenseDAL, keyStore }); + + const hsmService = hsmServiceFactory({ + pkcs11Module: hsmModule + }); + const kmsService = kmsServiceFactory({ kmsRootConfigDAL, keyStore, kmsDAL, internalKmsDAL, orgDAL, - projectDAL + projectDAL, + hsmService }); + const externalKmsService = externalKmsServiceFactory({ kmsDAL, kmsService, @@ -1265,6 +1281,7 @@ export const registerRoutes = async ( // setup the communication with license key server await licenseService.init(); + hsmService.startService(); await telemetryQueue.startTelemetryCheck(); await dailyResourceCleanUp.startCleanUp(); await dailyExpiringPkiItemAlert.startSendingAlerts(); @@ -1342,6 +1359,7 @@ export const registerRoutes = async ( secretSharing: secretSharingService, userEngagement: userEngagementService, externalKms: externalKmsService, + hsm: hsmService, cmek: cmekService, orgAdmin: orgAdminService, slack: slackService, diff --git a/backend/src/services/hsm/hsm-fns.ts b/backend/src/services/hsm/hsm-fns.ts new file mode 100644 index 000000000..83b05568e --- /dev/null +++ b/backend/src/services/hsm/hsm-fns.ts @@ -0,0 +1,40 @@ +import * as grapheneLib from "graphene-pk11"; + +import { getConfig } from "@app/lib/config/env"; +import { logger } from "@app/lib/logger"; + +export type HsmModule = { + module: grapheneLib.Module | null; + graphene: typeof grapheneLib; +}; + +export const initializePkcs11Module = () => { + const appCfg = getConfig(); + + let module: grapheneLib.Module | null = null; + + const initialize = () => { + if (!appCfg.isHsmConfigured) { + return; + } + + module = grapheneLib.Module.load(appCfg.HSM_LIB_PATH!, "SoftHSM"); + module.initialize(); + logger.info("PKCS#11 module initialized"); + }; + + const finalize = () => { + if (module) { + module.finalize(); + logger.info("PKCS#11 module finalized"); + } + }; + + const getModule = (): HsmModule => ({ module, graphene: grapheneLib }); + + return { + initialize, + finalize, + getModule + }; +}; diff --git a/backend/src/services/hsm/hsm-service.ts b/backend/src/services/hsm/hsm-service.ts new file mode 100644 index 000000000..c9c4fb4f6 --- /dev/null +++ b/backend/src/services/hsm/hsm-service.ts @@ -0,0 +1,276 @@ +import grapheneLib from "graphene-pk11"; + +import { getConfig } from "@app/lib/config/env"; +import { logger } from "@app/lib/logger"; + +import { HsmModule } from "./hsm-fns"; + +type THsmServiceFactoryDep = { + pkcs11Module: HsmModule; +}; +const SESSION_TIMEOUT = 5 * 60 * 1000; // 5 minutes +const USER_ALREADY_LOGGED_IN_ERROR = "CKR_USER_ALREADY_LOGGED_IN"; + +export type THsmServiceFactory = ReturnType; + +class HsmSessionManager { + private session: grapheneLib.Session | null = null; + + private lastUsed: number = 0; + + private module: grapheneLib.Module; + + private graphene: typeof grapheneLib; + + private sessionCheckInterval: NodeJS.Timeout | null = null; + + private startSessionMonitoring() { + // Check session health every minute + this.sessionCheckInterval = setInterval(() => { + this.checkAndRefreshSession(); + }, 60 * 1000); // 1 minute + } + + private checkAndRefreshSession() { + if (!this.session) return; + + const now = Date.now(); + if (now - this.lastUsed > SESSION_TIMEOUT) { + logger.info("Session expired, cleaning up..."); + this.cleanup(); + } + } + + private cleanup() { + if (this.session) { + try { + this.session.logout(); + this.session.close(); + } catch (error) { + logger.error("Error during session cleanup:", error); + } + this.session = null; + } + + if (this.sessionCheckInterval) { + clearInterval(this.sessionCheckInterval); + this.sessionCheckInterval = null; + } + } + + getSession(): grapheneLib.Session { + const appCfg = getConfig(); + + // If we have a valid session, update its last used time and return it + if (this.session) { + try { + // Try a simple operation to verify session is still valid + this.session.generateRandom(16); + this.lastUsed = Date.now(); + return this.session; + } catch (error) { + logger.info("HSM Session validation failed, creating new session..."); + this.cleanup(); + } + } + + // Create new session + const slot = this.module.getSlots(appCfg.HSM_SLOT); + // eslint-disable-next-line no-bitwise + if (!(slot.flags & this.graphene.SlotFlag.TOKEN_PRESENT)) { + throw new Error("Slot is not initialized"); + } + + // eslint-disable-next-line no-bitwise + const session = slot.open(this.graphene.SessionFlag.RW_SESSION | this.graphene.SessionFlag.SERIAL_SESSION); + + try { + session.login(appCfg.HSM_PIN!); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- The error is of type `Pkcs11Error`, but this error is not exported by graphene. And we don't want to install another library just for an error assertion. + if (error.message !== USER_ALREADY_LOGGED_IN_ERROR) { + throw error; + } + } + + this.session = session; + this.lastUsed = Date.now(); + + return session; + } + + constructor(module: grapheneLib.Module, graphene: typeof grapheneLib) { + this.module = module; + this.graphene = graphene; + + this.startSessionMonitoring(); + } +} + +// eslint-disable-next-line no-empty-pattern +export const hsmServiceFactory = ({ pkcs11Module: { module, graphene } }: THsmServiceFactoryDep) => { + const appCfg = getConfig(); + + // Constants for buffer structure + const IV_LENGTH = 16; + const TAG_LENGTH = 16; + + let sessionManager: HsmSessionManager | null = null; + + const $findKey = (session: grapheneLib.Session) => { + // Find the existing AES key + const template = { + class: graphene.ObjectClass.SECRET_KEY, + keyType: graphene.KeyType.AES, + label: appCfg.HSM_KEY_LABEL + } as grapheneLib.ITemplate; + + const key = session.find(template).items(0); + + if (!key) { + throw new Error("Failed to encrypt data, AES key not found"); + } + + return key; + }; + + const $keyExists = (session: grapheneLib.Session): boolean => { + try { + const key = $findKey(session); + // items(0) will throw an error if no items are found + // Return true only if we got a valid object with handle + return key && typeof key.handle !== "undefined"; + } catch (error) { + // If items(0) throws, it means no key was found + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-call + if ((error as any).message?.includes("CKR_OBJECT_HANDLE_INVALID")) { + return false; + } + logger.error(error, "Error checking for HSM key presence"); + return false; + } + }; + + const isActive = async () => { + if (!module || !appCfg.isHsmConfigured || !sessionManager) { + return false; + } + + return appCfg.isHsmConfigured && module !== null; + }; + + const startService = () => { + if (!appCfg.isHsmConfigured || !module) return; + + sessionManager = new HsmSessionManager(module, graphene); + const session = sessionManager.getSession(); + + try { + // Check if key already exists + if ($keyExists(session)) { + logger.info("Key already exists, skipping creation"); + } else { + // Generate 256-bit AES key with persistent storage + session.generateKey(graphene.KeyGenMechanism.AES, { + class: graphene.ObjectClass.SECRET_KEY, + token: true, // This ensures the key is stored persistently + valueLen: 256 / 8, + keyType: graphene.KeyType.AES, + label: appCfg.HSM_KEY_LABEL, + encrypt: true, + decrypt: true, + extractable: false, // Prevent key export + sensitive: true, // Mark as sensitive data + private: true // Require login to access + }); + logger.info(`Key created successfully with label: ${appCfg.HSM_KEY_LABEL}`); + } + + const mechs = session.slot.getMechanisms(); + let gotAesGcmMechanism = false; + + // eslint-disable-next-line no-plusplus + for (let i = 0; i < mechs.length; i++) { + const mech = mechs.items(i); + if (mech.name === "AES_GCM") { + gotAesGcmMechanism = true; + break; + } + } + + if (!gotAesGcmMechanism) { + throw new Error("Failed to initialize HSM. AES GCM encryption mechanism not supported by the HSM"); + } + } catch (error) { + logger.error(error, "Error creating HSM key"); + throw error; + } + }; + + function encrypt(data: Buffer): Buffer { + if (!module) { + throw new Error("PKCS#11 module is not initialized"); + } + + if (!sessionManager) { + throw new Error("HSM Session manager is not initialized"); + } + const session = sessionManager.getSession(); + const key = $findKey(session); + + // Generate IV + const iv = session.generateRandom(IV_LENGTH); + const alg = { + name: appCfg.HSM_MECHANISM, + params: new graphene.AesGcm240Params(iv) + } as grapheneLib.IAlgorithm; + + const cipher = session.createCipher(alg, new graphene.Key(key).toType()); + + // Calculate the output buffer size based on input length + // GCM adds a 16-byte auth tag, so we need input length + 16 + const outputBuffer = Buffer.alloc(data.length + TAG_LENGTH); + const encryptedData = cipher.once(data, outputBuffer); + + // Combine IV + encrypted data into a single buffer + // Format: [IV (16 bytes)][Encrypted Data][Auth Tag (16 bytes)] + return Buffer.concat([iv, encryptedData]); + } + + function decrypt(encryptedBlob: Buffer): Buffer { + if (!module) { + throw new Error("PKCS#11 module is not initialized"); + } + + if (!sessionManager) { + throw new Error("HSM Session manager is not initialized"); + } + + const session = sessionManager.getSession(); + const key = $findKey(session); + + // Extract IV, ciphertext, and tag from the blob + const iv = encryptedBlob.subarray(0, IV_LENGTH); + const ciphertext = encryptedBlob.subarray(IV_LENGTH, encryptedBlob.length); + + const algo = { + name: appCfg.HSM_MECHANISM, + params: new graphene.AesGcm240Params(iv) // Pass both IV and tag + }; + + const decipher = session.createDecipher(algo, new graphene.Key(key).toType()); + + // Allocate buffer for decrypted data + const outputBuffer = Buffer.alloc(ciphertext.length); + + const decrypted = decipher.once(ciphertext, outputBuffer); + return decrypted; + } + return { + encrypt, + startService, + isActive, + decrypt + }; +}; diff --git a/backend/src/services/kms/kms-service.ts b/backend/src/services/kms/kms-service.ts index 1b5c282a6..2a18d9ee1 100644 --- a/backend/src/services/kms/kms-service.ts +++ b/backend/src/services/kms/kms-service.ts @@ -19,6 +19,7 @@ import { logger } from "@app/lib/logger"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { getByteLengthForAlgorithm } from "@app/services/kms/kms-fns"; +import { THsmServiceFactory } from "../hsm/hsm-service"; import { TOrgDALFactory } from "../org/org-dal"; import { TProjectDALFactory } from "../project/project-dal"; import { TInternalKmsDALFactory } from "./internal-kms-dal"; @@ -40,9 +41,10 @@ type TKmsServiceFactoryDep = { kmsDAL: TKmsKeyDALFactory; projectDAL: Pick; orgDAL: Pick; - kmsRootConfigDAL: Pick; + kmsRootConfigDAL: Pick; keyStore: Pick; internalKmsDAL: Pick; + hsmService: THsmServiceFactory; }; export type TKmsServiceFactory = ReturnType; @@ -63,7 +65,8 @@ export const kmsServiceFactory = ({ keyStore, internalKmsDAL, orgDAL, - projectDAL + projectDAL, + hsmService }: TKmsServiceFactoryDep) => { let ROOT_ENCRYPTION_KEY = Buffer.alloc(0); @@ -801,6 +804,7 @@ export const kmsServiceFactory = ({ const isBase64 = !appCfg.ENCRYPTION_KEY; if (!encryptionKey) throw new Error("Root encryption key not found for KMS service."); const encryptionKeyBuffer = Buffer.from(encryptionKey, isBase64 ? "base64" : "utf8"); + const hsmEnabled = await hsmService.isActive(); const lock = await keyStore.acquireLock([`KMS_ROOT_CFG_LOCK`], 3000, { retryCount: 3 }).catch(() => null); if (!lock) { @@ -817,8 +821,43 @@ export const kmsServiceFactory = ({ if (kmsRootConfig) { if (lock) await lock.release(); logger.info("KMS: Encrypted ROOT Key found from DB. Decrypting."); - const decryptedRootKey = cipher.decrypt(kmsRootConfig.encryptedRootKey, encryptionKeyBuffer); - // set the flag so that other instancen nodes can start + + let decryptedRootKey: Buffer | null = null; + try { + decryptedRootKey = cipher.decrypt(kmsRootConfig.encryptedRootKey, encryptionKeyBuffer); + logger.info("KMS: Decrypted ROOT Key with platform key."); + } catch (err) { + // First we attempt to decrypt with regular root key. If it fails, we check if HSM is enabled, and if it is we attempt to decrypt with HSM. + if (hsmEnabled && kmsRootConfig.isEncryptedByHsm) { + decryptedRootKey = hsmService.decrypt(kmsRootConfig.encryptedRootKey); + logger.info("KMS: Decrypted ROOT Key with HSM."); + } else { + // If HSM is not enabled we assume it's a general error, and throw. + throw err; + } + } + + if (!decryptedRootKey) { + logger.error( + { hsmEnabled, isEncryptedByHsm: kmsRootConfig.isEncryptedByHsm }, + "KMS: Failed to decrypt ROOT Key" + ); + throw new Error("Failed to decrypt ROOT Key"); + } + + // If the key is not encrypted with HSM, we re-encrypt it with HSM and update the key in the DB. + if (!kmsRootConfig.isEncryptedByHsm && hsmEnabled) { + const encryptedRootKey = hsmService.encrypt(decryptedRootKey); + + if (!encryptedRootKey) { + logger.error("KMS: Failed to encrypt ROOT Key with HSM"); + throw new Error("Failed to encrypt ROOT Key with HSM"); + } + + await kmsRootConfigDAL.updateById(KMS_ROOT_CONFIG_UUID, { encryptedRootKey, isEncryptedByHsm: true }); + } + + // set the flag so that other instance nodes can start await keyStore.setItemWithExpiry(KMS_ROOT_CREATION_WAIT_KEY, KMS_ROOT_CREATION_WAIT_TIME, "true"); logger.info("KMS: Loading ROOT Key into Memory."); ROOT_ENCRYPTION_KEY = decryptedRootKey; @@ -827,9 +866,23 @@ export const kmsServiceFactory = ({ logger.info("KMS: Generating ROOT Key"); const newRootKey = randomSecureBytes(32); - const encryptedRootKey = cipher.encrypt(newRootKey, encryptionKeyBuffer); + + let encryptedRootKey: Buffer | null = null; + let isEncryptedByHsm = false; + + if (hsmEnabled) { + encryptedRootKey = hsmService.encrypt(newRootKey); + isEncryptedByHsm = true; + } else { + encryptedRootKey = cipher.encrypt(newRootKey, encryptionKeyBuffer); + } + + if (!encryptedRootKey) { + logger.error({ hsmEnabled }, "KMS: Failed to encrypt ROOT Key"); + } + // @ts-expect-error id is kept as fixed for idempotence and to avoid race condition - await kmsRootConfigDAL.create({ encryptedRootKey, id: KMS_ROOT_CONFIG_UUID }); + await kmsRootConfigDAL.create({ encryptedRootKey, id: KMS_ROOT_CONFIG_UUID, isEncryptedByHsm }); // set the flag so that other instancen nodes can start await keyStore.setItemWithExpiry(KMS_ROOT_CREATION_WAIT_KEY, KMS_ROOT_CREATION_WAIT_TIME, "true");