diff --git a/.github/workflows/release-standalone-docker-img-postgres-offical.yml b/.github/workflows/release-standalone-docker-img-postgres-offical.yml index f08e882aaa..7a73288cb1 100644 --- a/.github/workflows/release-standalone-docker-img-postgres-offical.yml +++ b/.github/workflows/release-standalone-docker-img-postgres-offical.yml @@ -1,62 +1,115 @@ name: Release standalone docker image on: - push: - tags: - - "infisical/v*.*.*-postgres" + push: + tags: + - "infisical/v*.*.*-postgres" jobs: - infisical-tests: - name: Run tests before deployment - # https://docs.github.com/en/actions/using-workflows/reusing-workflows#overview - uses: ./.github/workflows/run-backend-tests.yml - infisical-standalone: - name: Build infisical standalone image postgres - runs-on: ubuntu-latest - needs: [infisical-tests] - steps: - - name: Extract version from tag - id: extract_version - run: echo "::set-output name=version::${GITHUB_REF_NAME#infisical/}" - - name: ☁️ Checkout source - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - name: 📦 Install dependencies to test all dependencies - run: npm ci --only-production - working-directory: backend - - name: version output - run: | - echo "Output Value: ${{ steps.version.outputs.major }}" - echo "Output Value: ${{ steps.version.outputs.minor }}" - echo "Output Value: ${{ steps.version.outputs.patch }}" - echo "Output Value: ${{ steps.version.outputs.version }}" - echo "Output Value: ${{ steps.version.outputs.version_type }}" - echo "Output Value: ${{ steps.version.outputs.increment }}" - - name: Save commit hashes for tag - id: commit - uses: pr-mpt/actions-commit-hash@v2 - - name: 🔧 Set up Docker Buildx - uses: docker/setup-buildx-action@v2 - - name: 🐋 Login to Docker Hub - uses: docker/login-action@v2 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Set up Depot CLI - uses: depot/setup-action@v1 - - name: 📦 Build backend and export to Docker - uses: depot/build-push-action@v1 - with: - project: 64mmf0n610 - token: ${{ secrets.DEPOT_PROJECT_TOKEN }} - push: true - context: . - tags: | - infisical/infisical:latest-postgres - infisical/infisical:${{ steps.commit.outputs.short }} - infisical/infisical:${{ steps.extract_version.outputs.version }} - platforms: linux/amd64,linux/arm64 - file: Dockerfile.standalone-infisical - build-args: | - POSTHOG_API_KEY=${{ secrets.PUBLIC_POSTHOG_API_KEY }} - INFISICAL_PLATFORM_VERSION=${{ steps.extract_version.outputs.version }} + infisical-tests: + name: Run tests before deployment + # https://docs.github.com/en/actions/using-workflows/reusing-workflows#overview + uses: ./.github/workflows/run-backend-tests.yml + + infisical-standalone: + name: Build infisical standalone image postgres + runs-on: ubuntu-latest + needs: [infisical-tests] + steps: + - name: Extract version from tag + id: extract_version + run: echo "::set-output name=version::${GITHUB_REF_NAME#infisical/}" + - name: ☁️ Checkout source + uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: 📦 Install dependencies to test all dependencies + run: npm ci --only-production + working-directory: backend + - name: version output + run: | + echo "Output Value: ${{ steps.version.outputs.major }}" + echo "Output Value: ${{ steps.version.outputs.minor }}" + echo "Output Value: ${{ steps.version.outputs.patch }}" + echo "Output Value: ${{ steps.version.outputs.version }}" + echo "Output Value: ${{ steps.version.outputs.version_type }}" + echo "Output Value: ${{ steps.version.outputs.increment }}" + - name: Save commit hashes for tag + id: commit + uses: pr-mpt/actions-commit-hash@v2 + - name: 🔧 Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + - name: 🐋 Login to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Set up Depot CLI + uses: depot/setup-action@v1 + - name: 📦 Build backend and export to Docker + uses: depot/build-push-action@v1 + with: + project: 64mmf0n610 + token: ${{ secrets.DEPOT_PROJECT_TOKEN }} + push: true + context: . + tags: | + infisical/infisical:latest-postgres + infisical/infisical:${{ steps.commit.outputs.short }} + infisical/infisical:${{ steps.extract_version.outputs.version }} + platforms: linux/amd64,linux/arm64 + file: Dockerfile.standalone-infisical + build-args: | + POSTHOG_API_KEY=${{ secrets.PUBLIC_POSTHOG_API_KEY }} + INFISICAL_PLATFORM_VERSION=${{ steps.extract_version.outputs.version }} + + infisical-fips-standalone: + name: Build infisical standalone image postgres + runs-on: ubuntu-latest + needs: [infisical-tests] + steps: + - name: Extract version from tag + id: extract_version + run: echo "::set-output name=version::${GITHUB_REF_NAME#infisical/}" + - name: ☁️ Checkout source + uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: 📦 Install dependencies to test all dependencies + run: npm ci --only-production + working-directory: backend + - name: version output + run: | + echo "Output Value: ${{ steps.version.outputs.major }}" + echo "Output Value: ${{ steps.version.outputs.minor }}" + echo "Output Value: ${{ steps.version.outputs.patch }}" + echo "Output Value: ${{ steps.version.outputs.version }}" + echo "Output Value: ${{ steps.version.outputs.version_type }}" + echo "Output Value: ${{ steps.version.outputs.increment }}" + - name: Save commit hashes for tag + id: commit + uses: pr-mpt/actions-commit-hash@v2 + - name: 🔧 Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + - name: 🐋 Login to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Set up Depot CLI + uses: depot/setup-action@v1 + - name: 📦 Build backend and export to Docker + uses: depot/build-push-action@v1 + with: + project: 64mmf0n610 + token: ${{ secrets.DEPOT_PROJECT_TOKEN }} + push: true + context: . + tags: | + infisical/infisical-fips:latest-postgres + infisical/infisical-fips:${{ steps.commit.outputs.short }} + infisical/infisical-fips:${{ steps.extract_version.outputs.version }} + platforms: linux/amd64,linux/arm64 + file: Dockerfile.fips.standalone-infisical + build-args: | + POSTHOG_API_KEY=${{ secrets.PUBLIC_POSTHOG_API_KEY }} + INFISICAL_PLATFORM_VERSION=${{ steps.extract_version.outputs.version }} diff --git a/.infisicalignore b/.infisicalignore index b7fc38b356..4c19af70c5 100644 --- a/.infisicalignore +++ b/.infisicalignore @@ -6,3 +6,4 @@ frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/S docs/self-hosting/configuration/envars.mdx:generic-api-key:106 frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx:generic-api-key:451 docs/mint.json:generic-api-key:651 +backend/src/ee/services/hsm/hsm-service.ts:generic-api-key:134 diff --git a/Dockerfile.fips.standalone-infisical b/Dockerfile.fips.standalone-infisical new file mode 100644 index 0000000000..dfcb87deb3 --- /dev/null +++ b/Dockerfile.fips.standalone-infisical @@ -0,0 +1,167 @@ +ARG POSTHOG_HOST=https://app.posthog.com +ARG POSTHOG_API_KEY=posthog-api-key +ARG INTERCOM_ID=intercom-id +ARG CAPTCHA_SITE_KEY=captcha-site-key + +FROM node:20-slim AS base + +FROM base AS frontend-dependencies +WORKDIR /app + +COPY frontend/package.json frontend/package-lock.json frontend/next.config.js ./ + +# Install dependencies +RUN npm ci --only-production --ignore-scripts + +# Rebuild the source code only when needed +FROM base AS frontend-builder +WORKDIR /app + +# Copy dependencies +COPY --from=frontend-dependencies /app/node_modules ./node_modules +# Copy all files +COPY /frontend . + +ENV NODE_ENV production +ENV NEXT_PUBLIC_ENV production +ARG POSTHOG_HOST +ENV NEXT_PUBLIC_POSTHOG_HOST $POSTHOG_HOST +ARG POSTHOG_API_KEY +ENV NEXT_PUBLIC_POSTHOG_API_KEY $POSTHOG_API_KEY +ARG INTERCOM_ID +ENV NEXT_PUBLIC_INTERCOM_ID $INTERCOM_ID +ARG INFISICAL_PLATFORM_VERSION +ENV NEXT_PUBLIC_INFISICAL_PLATFORM_VERSION $INFISICAL_PLATFORM_VERSION +ARG CAPTCHA_SITE_KEY +ENV NEXT_PUBLIC_CAPTCHA_SITE_KEY $CAPTCHA_SITE_KEY + +# Build +RUN npm run build + +# Production image +FROM base AS frontend-runner +WORKDIR /app + +RUN groupadd -r -g 1001 nodejs && useradd -r -u 1001 -g nodejs non-root-user + +RUN mkdir -p /app/.next/cache/images && chown non-root-user:nodejs /app/.next/cache/images +VOLUME /app/.next/cache/images + +COPY --chown=non-root-user:nodejs --chmod=555 frontend/scripts ./scripts +COPY --from=frontend-builder /app/public ./public +RUN chown non-root-user:nodejs ./public/data + +COPY --from=frontend-builder --chown=non-root-user:nodejs /app/.next/standalone ./ +COPY --from=frontend-builder --chown=non-root-user:nodejs /app/.next/static ./.next/static + +USER non-root-user + +ENV NEXT_TELEMETRY_DISABLED 1 + +## +## BACKEND +## +FROM base AS backend-build + +ENV ChrystokiConfigurationPath=/usr/safenet/lunaclient/ + +RUN groupadd -r -g 1001 nodejs && useradd -r -u 1001 -g nodejs non-root-user + +WORKDIR /app + +# Required for pkcs11js +RUN apt-get update && apt-get install -y \ + python3 \ + make \ + g++ \ + && rm -rf /var/lib/apt/lists/* + +COPY backend/package*.json ./ +RUN npm ci --only-production + +COPY /backend . +COPY --chown=non-root-user:nodejs standalone-entrypoint.sh standalone-entrypoint.sh +RUN npm i -D tsconfig-paths +RUN npm run build + +# Production stage +FROM base AS backend-runner + +ENV ChrystokiConfigurationPath=/usr/safenet/lunaclient/ + +WORKDIR /app + +# Required for pkcs11js +RUN apt-get update && apt-get install -y \ + python3 \ + make \ + g++ \ + && rm -rf /var/lib/apt/lists/* + +COPY backend/package*.json ./ +RUN npm ci --only-production + +COPY --from=backend-build /app . + +RUN mkdir frontend-build + +# Production stage +FROM base AS production + +# Install necessary packages +RUN apt-get update && apt-get install -y \ + ca-certificates \ + curl \ + git \ + && rm -rf /var/lib/apt/lists/* + +# Install Infisical CLI +RUN curl -1sLf 'https://dl.cloudsmith.io/public/infisical/infisical-cli/setup.deb.sh' | bash \ + && apt-get update && apt-get install -y infisical=0.31.1 \ + && rm -rf /var/lib/apt/lists/* + +RUN groupadd -r -g 1001 nodejs && useradd -r -u 1001 -g nodejs non-root-user + +# Give non-root-user permission to update SSL certs +RUN chown -R non-root-user /etc/ssl/certs +RUN chown non-root-user /etc/ssl/certs/ca-certificates.crt +RUN chmod -R u+rwx /etc/ssl/certs +RUN chmod u+rw /etc/ssl/certs/ca-certificates.crt +RUN chown non-root-user /usr/sbin/update-ca-certificates +RUN chmod u+rx /usr/sbin/update-ca-certificates + +## set pre baked keys +ARG POSTHOG_API_KEY +ENV NEXT_PUBLIC_POSTHOG_API_KEY=$POSTHOG_API_KEY \ + BAKED_NEXT_PUBLIC_POSTHOG_API_KEY=$POSTHOG_API_KEY +ARG INTERCOM_ID=intercom-id +ENV NEXT_PUBLIC_INTERCOM_ID=$INTERCOM_ID \ + BAKED_NEXT_PUBLIC_INTERCOM_ID=$INTERCOM_ID +ARG CAPTCHA_SITE_KEY +ENV NEXT_PUBLIC_CAPTCHA_SITE_KEY=$CAPTCHA_SITE_KEY \ + BAKED_NEXT_PUBLIC_CAPTCHA_SITE_KEY=$CAPTCHA_SITE_KEY + +WORKDIR / + +COPY --from=backend-runner /app /backend + +COPY --from=frontend-runner /app ./backend/frontend-build + +ENV PORT 8080 +ENV HOST=0.0.0.0 +ENV HTTPS_ENABLED false +ENV NODE_ENV production +ENV STANDALONE_BUILD true +ENV STANDALONE_MODE true +ENV ChrystokiConfigurationPath=/usr/safenet/lunaclient/ + +WORKDIR /backend + +ENV TELEMETRY_ENABLED true + +EXPOSE 8080 +EXPOSE 443 + +USER non-root-user + +CMD ["./standalone-entrypoint.sh"] \ No newline at end of file diff --git a/Dockerfile.standalone-infisical b/Dockerfile.standalone-infisical index 269cbfcf9c..41c898b793 100644 --- a/Dockerfile.standalone-infisical +++ b/Dockerfile.standalone-infisical @@ -72,6 +72,9 @@ RUN addgroup --system --gid 1001 nodejs \ WORKDIR /app +# Required for pkcs11js +RUN apk add --no-cache python3 make g++ + COPY backend/package*.json ./ RUN npm ci --only-production @@ -85,6 +88,9 @@ FROM base AS backend-runner WORKDIR /app +# Required for pkcs11js +RUN apk add --no-cache python3 make g++ + COPY backend/package*.json ./ RUN npm ci --only-production diff --git a/backend/Dockerfile b/backend/Dockerfile index 2153ba33a1..5822649461 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -3,6 +3,12 @@ FROM node:20-alpine AS build WORKDIR /app +# Required for pkcs11js +RUN apk --update add \ + python3 \ + make \ + g++ + COPY package*.json ./ RUN npm ci --only-production @@ -11,12 +17,17 @@ RUN npm run build # Production stage FROM node:20-alpine - WORKDIR /app ENV npm_config_cache /home/node/.npm COPY package*.json ./ + +RUN apk --update add \ + python3 \ + make \ + g++ + RUN npm ci --only-production && npm cache clean --force COPY --from=build /app . diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev index ec34f63d7b..97bc2c6a36 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/e2e-test/vitest-environment-knex.ts b/backend/e2e-test/vitest-environment-knex.ts index 7be0b860f8..866b0f45ff 100644 --- a/backend/e2e-test/vitest-environment-knex.ts +++ b/backend/e2e-test/vitest-environment-knex.ts @@ -16,6 +16,7 @@ import { initDbConnection } from "@app/db"; import { queueServiceFactory } from "@app/queue"; import { keyStoreFactory } from "@app/keystore/keystore"; import { Redis } from "ioredis"; +import { initializeHsmModule } from "@app/ee/services/hsm/hsm-fns"; dotenv.config({ path: path.join(__dirname, "../../.env.test"), debug: true }); export default { @@ -54,7 +55,12 @@ export default { const smtp = mockSmtpServer(); const queue = queueServiceFactory(cfg.REDIS_URL); const keyStore = keyStoreFactory(cfg.REDIS_URL); - const server = await main({ db, smtp, logger, queue, keyStore }); + + const hsmModule = initializeHsmModule(); + hsmModule.initialize(); + + const server = await main({ db, smtp, logger, queue, keyStore, hsmModule: hsmModule.getModule() }); + // @ts-expect-error type globalThis.testServer = server; // @ts-expect-error type diff --git a/backend/package-lock.json b/backend/package-lock.json index 6829ebbc89..d55521f5bf 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -83,6 +83,7 @@ "pg-query-stream": "^4.5.3", "picomatch": "^3.0.1", "pino": "^8.16.2", + "pkcs11js": "^2.1.6", "pkijs": "^3.2.4", "posthog-node": "^3.6.2", "probot": "^13.3.8", @@ -120,6 +121,7 @@ "@types/passport-google-oauth20": "^2.0.14", "@types/pg": "^8.10.9", "@types/picomatch": "^2.3.3", + "@types/pkcs11js": "^1.0.4", "@types/prompt-sync": "^4.2.3", "@types/resolve": "^1.20.6", "@types/safe-regex": "^1.1.6", @@ -8830,6 +8832,17 @@ "integrity": "sha512-Yll76ZHikRFCyz/pffKGjrCwe/le2CDwOP5F210KQo27kpRE46U2rDnzikNlVn6/ezH3Mhn46bJMTfeVTtcYMg==", "dev": true }, + "node_modules/@types/pkcs11js": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@types/pkcs11js/-/pkcs11js-1.0.4.tgz", + "integrity": "sha512-Pkq8VbwZZv7o/6ODFOhxw0s0M8J4ucg4/I4V1dSCn8tUwWgIKIYzuV4Pp2fYuir81DgQXAF5TpGyhBMjJ3FjFw==", + "deprecated": "This is a stub types definition for pkcs11js (https://github.com/PeculiarVentures/pkcs11js). pkcs11js provides its own type definitions, so you don't need @types/pkcs11js installed!", + "dev": true, + "license": "MIT", + "dependencies": { + "pkcs11js": "*" + } + }, "node_modules/@types/prompt-sync": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/@types/prompt-sync/-/prompt-sync-4.2.3.tgz", @@ -17066,6 +17079,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 +19788,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 1336478a1f..240b7db2c2 100644 --- a/backend/package.json +++ b/backend/package.json @@ -84,6 +84,7 @@ "@types/passport-google-oauth20": "^2.0.14", "@types/pg": "^8.10.9", "@types/picomatch": "^2.3.3", + "@types/pkcs11js": "^1.0.4", "@types/prompt-sync": "^4.2.3", "@types/resolve": "^1.20.6", "@types/safe-regex": "^1.1.6", @@ -188,6 +189,7 @@ "pg-query-stream": "^4.5.3", "picomatch": "^3.0.1", "pino": "^8.16.2", + "pkcs11js": "^2.1.6", "pkijs": "^3.2.4", "posthog-node": "^3.6.2", "probot": "^13.3.8", diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index d79c224e5a..ebe3f4289d 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/20241111175154_kms-root-cfg-hsm.ts b/backend/src/db/migrations/20241111175154_kms-root-cfg-hsm.ts new file mode 100644 index 0000000000..501eccb8b8 --- /dev/null +++ b/backend/src/db/migrations/20241111175154_kms-root-cfg-hsm.ts @@ -0,0 +1,23 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasEncryptionStrategy = await knex.schema.hasColumn(TableName.KmsServerRootConfig, "encryptionStrategy"); + const hasTimestampsCol = await knex.schema.hasColumn(TableName.KmsServerRootConfig, "createdAt"); + + await knex.schema.alterTable(TableName.KmsServerRootConfig, (t) => { + if (!hasEncryptionStrategy) t.string("encryptionStrategy").defaultTo("SOFTWARE"); + if (!hasTimestampsCol) t.timestamps(true, true, true); + }); +} + +export async function down(knex: Knex): Promise { + const hasEncryptionStrategy = await knex.schema.hasColumn(TableName.KmsServerRootConfig, "encryptionStrategy"); + const hasTimestampsCol = await knex.schema.hasColumn(TableName.KmsServerRootConfig, "createdAt"); + + await knex.schema.alterTable(TableName.KmsServerRootConfig, (t) => { + if (hasEncryptionStrategy) t.dropColumn("encryptionStrategy"); + 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 d2c0edbc5e..d15e1dff89 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, + encryptionStrategy: z.string(), + createdAt: z.date(), + updatedAt: z.date() }); export type TKmsRootConfig = z.infer; diff --git a/backend/src/ee/services/hsm/hsm-fns.ts b/backend/src/ee/services/hsm/hsm-fns.ts new file mode 100644 index 0000000000..f91f9a0042 --- /dev/null +++ b/backend/src/ee/services/hsm/hsm-fns.ts @@ -0,0 +1,58 @@ +import * as pkcs11js from "pkcs11js"; + +import { getConfig } from "@app/lib/config/env"; +import { logger } from "@app/lib/logger"; + +import { HsmModule } from "./hsm-types"; + +export const initializeHsmModule = () => { + const appCfg = getConfig(); + + // Create a new instance of PKCS11 module + const pkcs11 = new pkcs11js.PKCS11(); + let isInitialized = false; + + const initialize = () => { + if (!appCfg.isHsmConfigured) { + return; + } + + try { + // Load the PKCS#11 module + pkcs11.load(appCfg.HSM_LIB_PATH!); + + // Initialize the module + pkcs11.C_Initialize(); + isInitialized = true; + + logger.info("PKCS#11 module initialized"); + } catch (err) { + logger.error("Failed to initialize PKCS#11 module:", err); + throw err; + } + }; + + const finalize = () => { + if (isInitialized) { + try { + pkcs11.C_Finalize(); + isInitialized = false; + logger.info("PKCS#11 module finalized"); + } catch (err) { + logger.error("Failed to finalize PKCS#11 module:", err); + throw err; + } + } + }; + + const getModule = (): HsmModule => ({ + pkcs11, + isInitialized + }); + + return { + initialize, + finalize, + getModule + }; +}; diff --git a/backend/src/ee/services/hsm/hsm-service.ts b/backend/src/ee/services/hsm/hsm-service.ts new file mode 100644 index 0000000000..a1a0773fc4 --- /dev/null +++ b/backend/src/ee/services/hsm/hsm-service.ts @@ -0,0 +1,470 @@ +import pkcs11js from "pkcs11js"; + +import { getConfig } from "@app/lib/config/env"; +import { logger } from "@app/lib/logger"; + +import { HsmKeyType, HsmModule } from "./hsm-types"; + +type THsmServiceFactoryDep = { + hsmModule: HsmModule; +}; + +export type THsmServiceFactory = ReturnType; + +type SyncOrAsync = T | Promise; +type SessionCallback = (session: pkcs11js.Handle) => SyncOrAsync; + +// eslint-disable-next-line no-empty-pattern +export const hsmServiceFactory = ({ hsmModule: { isInitialized, pkcs11 } }: THsmServiceFactoryDep) => { + const appCfg = getConfig(); + + // Constants for buffer structures + const IV_LENGTH = 16; // Luna HSM typically expects 16-byte IV for cbc + const BLOCK_SIZE = 16; + const HMAC_SIZE = 32; + + const AES_KEY_SIZE = 256; + const HMAC_KEY_SIZE = 256; + + const $withSession = async (callbackWithSession: SessionCallback): Promise => { + const RETRY_INTERVAL = 200; // 200ms between attempts + const MAX_TIMEOUT = 90_000; // 90 seconds maximum total time + + let sessionHandle: pkcs11js.Handle | null = null; + + const removeSession = () => { + if (sessionHandle !== null) { + try { + pkcs11.C_Logout(sessionHandle); + pkcs11.C_CloseSession(sessionHandle); + logger.info("HSM: Terminated session successfully"); + } catch (error) { + logger.error(error, "HSM: Failed to terminate session"); + } finally { + sessionHandle = null; + } + } + }; + + try { + if (!pkcs11 || !isInitialized) { + throw new Error("PKCS#11 module is not initialized"); + } + + // Get slot list + let slots: pkcs11js.Handle[]; + try { + slots = pkcs11.C_GetSlotList(false); // false to get all slots + } catch (error) { + throw new Error(`Failed to get slot list: ${(error as Error)?.message}`); + } + + if (slots.length === 0) { + throw new Error("No slots available"); + } + + if (appCfg.HSM_SLOT >= slots.length) { + throw new Error(`HSM slot ${appCfg.HSM_SLOT} not found or not initialized`); + } + + const slotId = slots[appCfg.HSM_SLOT]; + + const startTime = Date.now(); + while (Date.now() - startTime < MAX_TIMEOUT) { + try { + // Open session + // eslint-disable-next-line no-bitwise + sessionHandle = pkcs11.C_OpenSession(slotId, pkcs11js.CKF_SERIAL_SESSION | pkcs11js.CKF_RW_SESSION); + + // Login + try { + pkcs11.C_Login(sessionHandle, pkcs11js.CKU_USER, appCfg.HSM_PIN); + logger.info("HSM: Successfully authenticated"); + break; + } catch (error) { + // Handle specific error cases + if (error instanceof pkcs11js.Pkcs11Error) { + if (error.code === pkcs11js.CKR_PIN_INCORRECT) { + // We throw instantly here to prevent further attempts, because if too many attempts are made, the HSM will potentially wipe all key material + logger.error(error, `HSM: Incorrect PIN detected for HSM slot ${appCfg.HSM_SLOT}`); + throw new Error("HSM: Incorrect HSM Pin detected. Please check the HSM configuration."); + } + if (error.code === pkcs11js.CKR_USER_ALREADY_LOGGED_IN) { + logger.warn("HSM: Session already logged in"); + } + } + throw error; // Re-throw other errors + } + } catch (error) { + logger.warn(`HSM: Session creation failed. Retrying... Error: ${(error as Error)?.message}`); + + if (sessionHandle !== null) { + try { + pkcs11.C_CloseSession(sessionHandle); + } catch (closeError) { + logger.error(closeError, "HSM: Failed to close session"); + } + sessionHandle = null; + } + + // Wait before retrying + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => { + setTimeout(resolve, RETRY_INTERVAL); + }); + } + } + + if (sessionHandle === null) { + throw new Error("HSM: Failed to open session after maximum retries"); + } + + // Execute callback with session handle + const result = await callbackWithSession(sessionHandle); + removeSession(); + return result; + } catch (error) { + logger.error(error, "HSM: Failed to open session"); + throw error; + } finally { + // Ensure cleanup + removeSession(); + } + }; + + const $findKey = (sessionHandle: pkcs11js.Handle, type: HsmKeyType) => { + const label = type === HsmKeyType.HMAC ? `${appCfg.HSM_KEY_LABEL}_HMAC` : appCfg.HSM_KEY_LABEL; + const keyType = type === HsmKeyType.HMAC ? pkcs11js.CKK_GENERIC_SECRET : pkcs11js.CKK_AES; + + const template = [ + { type: pkcs11js.CKA_CLASS, value: pkcs11js.CKO_SECRET_KEY }, + { type: pkcs11js.CKA_KEY_TYPE, value: keyType }, + { type: pkcs11js.CKA_LABEL, value: label } + ]; + + try { + // Initialize search + pkcs11.C_FindObjectsInit(sessionHandle, template); + + try { + // Find first matching object + const handles = pkcs11.C_FindObjects(sessionHandle, 1); + + if (handles.length === 0) { + throw new Error("Failed to find master key"); + } + + return handles[0]; // Return the key handle + } finally { + // Always finalize the search operation + pkcs11.C_FindObjectsFinal(sessionHandle); + } + } catch (error) { + return null; + } + }; + + const $keyExists = (session: pkcs11js.Handle, type: HsmKeyType): boolean => { + try { + const key = $findKey(session, type); + // items(0) will throw an error if no items are found + // Return true only if we got a valid object with handle + return !!key && key.length > 0; + } 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 + logger.error(error, "HSM: Failed while checking for HSM key presence"); + + if (error instanceof pkcs11js.Pkcs11Error) { + if (error.code === pkcs11js.CKR_OBJECT_HANDLE_INVALID) { + return false; + } + } + + return false; + } + }; + + const encrypt: { + (data: Buffer, providedSession: pkcs11js.Handle): Promise; + (data: Buffer): Promise; + } = async (data: Buffer, providedSession?: pkcs11js.Handle) => { + if (!pkcs11 || !isInitialized) { + throw new Error("PKCS#11 module is not initialized"); + } + + const $performEncryption = (sessionHandle: pkcs11js.Handle) => { + try { + const aesKey = $findKey(sessionHandle, HsmKeyType.AES); + if (!aesKey) { + throw new Error("HSM: Encryption failed, AES key not found"); + } + + const hmacKey = $findKey(sessionHandle, HsmKeyType.HMAC); + if (!hmacKey) { + throw new Error("HSM: Encryption failed, HMAC key not found"); + } + + const iv = Buffer.alloc(IV_LENGTH); + pkcs11.C_GenerateRandom(sessionHandle, iv); + + const encryptMechanism = { + mechanism: pkcs11js.CKM_AES_CBC_PAD, + parameter: iv + }; + + pkcs11.C_EncryptInit(sessionHandle, encryptMechanism, aesKey); + + // Calculate max buffer size (input length + potential full block of padding) + const maxEncryptedLength = Math.ceil(data.length / BLOCK_SIZE) * BLOCK_SIZE + BLOCK_SIZE; + + // Encrypt the data - this returns the encrypted data directly + const encryptedData = pkcs11.C_Encrypt(sessionHandle, data, Buffer.alloc(maxEncryptedLength)); + + // Initialize HMAC + const hmacMechanism = { + mechanism: pkcs11js.CKM_SHA256_HMAC + }; + + pkcs11.C_SignInit(sessionHandle, hmacMechanism, hmacKey); + + // Sign the IV and encrypted data + pkcs11.C_SignUpdate(sessionHandle, iv); + pkcs11.C_SignUpdate(sessionHandle, encryptedData); + + // Get the HMAC + const hmac = Buffer.alloc(HMAC_SIZE); + pkcs11.C_SignFinal(sessionHandle, hmac); + + // Combine encrypted data and HMAC [Encrypted Data | HMAC] + const finalBuffer = Buffer.alloc(encryptedData.length + hmac.length); + encryptedData.copy(finalBuffer); + hmac.copy(finalBuffer, encryptedData.length); + + return Buffer.concat([iv, finalBuffer]); + } catch (error) { + logger.error(error, "HSM: Failed to perform encryption"); + throw new Error(`HSM: Encryption failed: ${(error as Error)?.message}`); + } + }; + + if (providedSession) { + return $performEncryption(providedSession); + } + + const result = await $withSession($performEncryption); + return result; + }; + + const decrypt: { + (encryptedBlob: Buffer, providedSession: pkcs11js.Handle): Promise; + (encryptedBlob: Buffer): Promise; + } = async (encryptedBlob: Buffer, providedSession?: pkcs11js.Handle) => { + if (!pkcs11 || !isInitialized) { + throw new Error("PKCS#11 module is not initialized"); + } + + const $performDecryption = (sessionHandle: pkcs11js.Handle) => { + try { + // structure is: [IV (16 bytes) | Encrypted Data (N bytes) | HMAC (32 bytes)] + const iv = encryptedBlob.subarray(0, IV_LENGTH); + const encryptedDataWithHmac = encryptedBlob.subarray(IV_LENGTH); + + // Split encrypted data and HMAC + const hmac = encryptedDataWithHmac.subarray(-HMAC_SIZE); // Last 32 bytes are HMAC + + const encryptedData = encryptedDataWithHmac.subarray(0, -HMAC_SIZE); // Everything except last 32 bytes + + // Find the keys + const aesKey = $findKey(sessionHandle, HsmKeyType.AES); + if (!aesKey) { + throw new Error("HSM: Decryption failed, AES key not found"); + } + + const hmacKey = $findKey(sessionHandle, HsmKeyType.HMAC); + if (!hmacKey) { + throw new Error("HSM: Decryption failed, HMAC key not found"); + } + + // Verify HMAC first + const hmacMechanism = { + mechanism: pkcs11js.CKM_SHA256_HMAC + }; + + pkcs11.C_VerifyInit(sessionHandle, hmacMechanism, hmacKey); + pkcs11.C_VerifyUpdate(sessionHandle, iv); + pkcs11.C_VerifyUpdate(sessionHandle, encryptedData); + + try { + pkcs11.C_VerifyFinal(sessionHandle, hmac); + } catch (error) { + logger.error(error, "HSM: HMAC verification failed"); + throw new Error("HSM: Decryption failed"); // Generic error for failed verification + } + + // Only decrypt if verification passed + const decryptMechanism = { + mechanism: pkcs11js.CKM_AES_CBC_PAD, + parameter: iv + }; + + pkcs11.C_DecryptInit(sessionHandle, decryptMechanism, aesKey); + + const tempBuffer = Buffer.alloc(encryptedData.length); + const decryptedData = pkcs11.C_Decrypt(sessionHandle, encryptedData, tempBuffer); + + // Create a new buffer from the decrypted data + return Buffer.from(decryptedData); + } catch (error) { + logger.error(error, "HSM: Failed to perform decryption"); + throw new Error("HSM: Decryption failed"); // Generic error for failed decryption, to avoid leaking details about why it failed (such as padding related errors) + } + }; + + if (providedSession) { + return $performDecryption(providedSession); + } + + const result = await $withSession($performDecryption); + return result; + }; + + // We test the core functionality of the PKCS#11 module that we are using throughout Infisical. This is to ensure that the user doesn't configure a faulty or unsupported HSM device. + const $testPkcs11Module = async (session: pkcs11js.Handle) => { + try { + if (!pkcs11 || !isInitialized) { + throw new Error("PKCS#11 module is not initialized"); + } + + if (!session) { + throw new Error("HSM: Attempted to run test without a valid session"); + } + + const randomData = pkcs11.C_GenerateRandom(session, Buffer.alloc(500)); + + const encryptedData = await encrypt(randomData, session); + const decryptedData = await decrypt(encryptedData, session); + + const randomDataHex = randomData.toString("hex"); + const decryptedDataHex = decryptedData.toString("hex"); + + if (randomDataHex !== decryptedDataHex && Buffer.compare(randomData, decryptedData)) { + throw new Error("HSM: Startup test failed. Decrypted data does not match original data"); + } + + return true; + } catch (error) { + logger.error(error, "HSM: Error testing PKCS#11 module"); + return false; + } + }; + + const isActive = async () => { + if (!isInitialized || !appCfg.isHsmConfigured) { + return false; + } + + let pkcs11TestPassed = false; + + try { + pkcs11TestPassed = await $withSession($testPkcs11Module); + } catch (err) { + logger.error(err, "HSM: Error testing PKCS#11 module"); + } + + return appCfg.isHsmConfigured && isInitialized && pkcs11TestPassed; + }; + + const startService = async () => { + if (!appCfg.isHsmConfigured || !pkcs11 || !isInitialized) return; + + try { + await $withSession(async (sessionHandle) => { + // Check if master key exists, create if not + + const genericAttributes = [ + { type: pkcs11js.CKA_TOKEN, value: true }, // Persistent storage + { type: pkcs11js.CKA_EXTRACTABLE, value: false }, // Cannot be extracted + { type: pkcs11js.CKA_SENSITIVE, value: true }, // Sensitive value + { type: pkcs11js.CKA_PRIVATE, value: true } // Requires authentication + ]; + + if (!$keyExists(sessionHandle, HsmKeyType.AES)) { + // Template for generating 256-bit AES master key + const keyTemplate = [ + { type: pkcs11js.CKA_CLASS, value: pkcs11js.CKO_SECRET_KEY }, + { type: pkcs11js.CKA_KEY_TYPE, value: pkcs11js.CKK_AES }, + { type: pkcs11js.CKA_VALUE_LEN, value: AES_KEY_SIZE / 8 }, + { type: pkcs11js.CKA_LABEL, value: appCfg.HSM_KEY_LABEL! }, + { type: pkcs11js.CKA_ENCRYPT, value: true }, // Allow encryption + { type: pkcs11js.CKA_DECRYPT, value: true }, // Allow decryption + ...genericAttributes + ]; + + // Generate the key + pkcs11.C_GenerateKey( + sessionHandle, + { + mechanism: pkcs11js.CKM_AES_KEY_GEN + }, + keyTemplate + ); + + logger.info(`HSM: Master key created successfully with label: ${appCfg.HSM_KEY_LABEL}`); + } + + // Check if HMAC key exists, create if not + if (!$keyExists(sessionHandle, HsmKeyType.HMAC)) { + const hmacKeyTemplate = [ + { type: pkcs11js.CKA_CLASS, value: pkcs11js.CKO_SECRET_KEY }, + { type: pkcs11js.CKA_KEY_TYPE, value: pkcs11js.CKK_GENERIC_SECRET }, + { type: pkcs11js.CKA_VALUE_LEN, value: HMAC_KEY_SIZE / 8 }, // 256-bit key + { type: pkcs11js.CKA_LABEL, value: `${appCfg.HSM_KEY_LABEL!}_HMAC` }, + { type: pkcs11js.CKA_SIGN, value: true }, // Allow signing + { type: pkcs11js.CKA_VERIFY, value: true }, // Allow verification + ...genericAttributes + ]; + + // Generate the HMAC key + pkcs11.C_GenerateKey( + sessionHandle, + { + mechanism: pkcs11js.CKM_GENERIC_SECRET_KEY_GEN + }, + hmacKeyTemplate + ); + + logger.info(`HSM: HMAC key created successfully with label: ${appCfg.HSM_KEY_LABEL}_HMAC`); + } + + // Get slot info to check supported mechanisms + const slotId = pkcs11.C_GetSessionInfo(sessionHandle).slotID; + const mechanisms = pkcs11.C_GetMechanismList(slotId); + + // Check for AES CBC PAD support + const hasAesCbc = mechanisms.includes(pkcs11js.CKM_AES_CBC_PAD); + + if (!hasAesCbc) { + throw new Error(`Required mechanism CKM_AEC_CBC_PAD not supported by HSM`); + } + + // Run test encryption/decryption + const testPassed = await $testPkcs11Module(sessionHandle); + + if (!testPassed) { + throw new Error("PKCS#11 module test failed. Please ensure that the HSM is correctly configured."); + } + }); + } catch (error) { + logger.error(error, "HSM: Error initializing HSM service:"); + throw error; + } + }; + + return { + encrypt, + startService, + isActive, + decrypt + }; +}; diff --git a/backend/src/ee/services/hsm/hsm-types.ts b/backend/src/ee/services/hsm/hsm-types.ts new file mode 100644 index 0000000000..b688147f58 --- /dev/null +++ b/backend/src/ee/services/hsm/hsm-types.ts @@ -0,0 +1,11 @@ +import pkcs11js from "pkcs11js"; + +export type HsmModule = { + pkcs11: pkcs11js.PKCS11; + isInitialized: boolean; +}; + +export enum HsmKeyType { + AES = "AES", + HMAC = "hmac" +} diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index 0864ab33cb..70c2995641 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -29,6 +29,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ auditLogStreams: false, auditLogStreamLimit: 3, samlSSO: false, + hsm: false, oidcSSO: false, scim: false, ldap: false, diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index 0ba54afc31..622b0e06b0 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -46,6 +46,7 @@ export type TFeatureSet = { auditLogStreams: false; auditLogStreamLimit: 3; samlSSO: false; + hsm: false; oidcSSO: false; scim: false; ldap: false; diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 638f21e5d7..72e0c0d058 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -163,10 +163,22 @@ 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()), + HSM_PIN: zpStr(z.string().optional()), + HSM_KEY_LABEL: zpStr(z.string().optional()), + HSM_SLOT: z.coerce.number().optional().default(0) }) + // To ensure that basic encryption is always possible. + .refine( + (data) => Boolean(data.ENCRYPTION_KEY) || Boolean(data.ROOT_ENCRYPTION_KEY), + "Either ENCRYPTION_KEY or ROOT_ENCRYPTION_KEY must be defined." + ) .transform((data) => ({ ...data, + DB_READ_REPLICAS: data.DB_READ_REPLICAS ? databaseReadReplicaSchema.parse(JSON.parse(data.DB_READ_REPLICAS)) : undefined, @@ -175,10 +187,14 @@ 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) && 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 f71a1fe95a..f8adea33f8 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -1,6 +1,8 @@ import dotenv from "dotenv"; import path from "path"; +import { initializeHsmModule } from "@app/ee/services/hsm/hsm-fns"; + import { initAuditLogDbConnection, initDbConnection } from "./db"; import { keyStoreFactory } from "./keystore/keystore"; import { formatSmtpConfig, initEnvConfig, IS_PACKAGED } from "./lib/config/env"; @@ -53,13 +55,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 hsmModule = initializeHsmModule(); + hsmModule.initialize(); + + const server = await main({ db, auditLogDb, hsmModule: hsmModule.getModule(), smtp, logger, queue, keyStore }); const bootstrap = await bootstrapCheck({ db }); // eslint-disable-next-line process.on("SIGINT", async () => { await server.close(); await db.destroy(); + hsmModule.finalize(); process.exit(0); }); @@ -67,6 +73,7 @@ const run = async () => { process.on("SIGTERM", async () => { await server.close(); await db.destroy(); + hsmModule.finalize(); process.exit(0); }); diff --git a/backend/src/server/app.ts b/backend/src/server/app.ts index b768d0db5b..6484860ea7 100644 --- a/backend/src/server/app.ts +++ b/backend/src/server/app.ts @@ -14,6 +14,7 @@ import fastify from "fastify"; import { Knex } from "knex"; import { Logger } from "pino"; +import { HsmModule } from "@app/ee/services/hsm/hsm-types"; import { TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig, IS_PACKAGED } from "@app/lib/config/env"; import { TQueueServiceFactory } from "@app/queue"; @@ -36,16 +37,19 @@ 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, trustProxy: true, - connectionTimeout: 30 * 1000, - ignoreTrailingSlash: true + connectionTimeout: appCfg.isHsmConfigured ? 90_000 : 30_000, + ignoreTrailingSlash: true, + pluginTimeout: 40_000 }).withTypeProvider(); server.setValidatorCompiler(validatorCompiler); @@ -95,7 +99,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 b2d0970925..88bfaaaae0 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"; @@ -31,6 +30,8 @@ import { externalKmsServiceFactory } from "@app/ee/services/external-kms/externa import { groupDALFactory } from "@app/ee/services/group/group-dal"; import { groupServiceFactory } from "@app/ee/services/group/group-service"; import { userGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; +import { hsmServiceFactory } from "@app/ee/services/hsm/hsm-service"; +import { HsmModule } from "@app/ee/services/hsm/hsm-types"; import { identityProjectAdditionalPrivilegeDALFactory } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-dal"; import { identityProjectAdditionalPrivilegeServiceFactory } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service"; import { identityProjectAdditionalPrivilegeV2ServiceFactory } from "@app/ee/services/identity-project-additional-privilege-v2/identity-project-additional-privilege-v2-service"; @@ -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({ + hsmModule + }); + const kmsService = kmsServiceFactory({ kmsRootConfigDAL, keyStore, kmsDAL, internalKmsDAL, orgDAL, - projectDAL + projectDAL, + hsmService }); + const externalKmsService = externalKmsServiceFactory({ kmsDAL, kmsService, @@ -556,6 +572,7 @@ export const registerRoutes = async ( userDAL, authService: loginService, serverCfgDAL: superAdminDAL, + kmsRootConfigDAL, orgService, keyStore, licenseService, @@ -1261,10 +1278,13 @@ export const registerRoutes = async ( }); await superAdminService.initServerCfg(); - // + // setup the communication with license key server await licenseService.init(); + // Start HSM service if it's configured/enabled. + await hsmService.startService(); + await telemetryQueue.startTelemetryCheck(); await dailyResourceCleanUp.startCleanUp(); await dailyExpiringPkiItemAlert.startSendingAlerts(); @@ -1342,6 +1362,7 @@ export const registerRoutes = async ( secretSharing: secretSharingService, userEngagement: userEngagementService, externalKms: externalKmsService, + hsm: hsmService, cmek: cmekService, orgAdmin: orgAdminService, slack: slackService, diff --git a/backend/src/server/routes/v1/admin-router.ts b/backend/src/server/routes/v1/admin-router.ts index bc0c725f06..6ecebb274c 100644 --- a/backend/src/server/routes/v1/admin-router.ts +++ b/backend/src/server/routes/v1/admin-router.ts @@ -7,6 +7,7 @@ import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifySuperAdmin } from "@app/server/plugins/auth/superAdmin"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; +import { RootKeyEncryptionStrategy } from "@app/services/kms/kms-types"; import { getServerCfg } from "@app/services/super-admin/super-admin-service"; import { LoginMethod } from "@app/services/super-admin/super-admin-types"; import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; @@ -195,6 +196,57 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "GET", + url: "/encryption-strategies", + config: { + rateLimit: readLimit + }, + schema: { + response: { + 200: z.object({ + strategies: z + .object({ + strategy: z.nativeEnum(RootKeyEncryptionStrategy), + enabled: z.boolean() + }) + .array() + }) + } + }, + onRequest: (req, res, done) => { + verifyAuth([AuthMode.JWT])(req, res, () => { + verifySuperAdmin(req, res, done); + }); + }, + + handler: async () => { + const encryptionDetails = await server.services.superAdmin.getConfiguredEncryptionStrategies(); + return encryptionDetails; + } + }); + + server.route({ + method: "PATCH", + url: "/encryption-strategies", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + strategy: z.nativeEnum(RootKeyEncryptionStrategy) + }) + }, + onRequest: (req, res, done) => { + verifyAuth([AuthMode.JWT])(req, res, () => { + verifySuperAdmin(req, res, done); + }); + }, + handler: async (req) => { + await server.services.superAdmin.updateRootEncryptionStrategy(req.body.strategy); + } + }); + server.route({ method: "POST", url: "/signup", diff --git a/backend/src/services/kms/kms-fns.ts b/backend/src/services/kms/kms-fns.ts index 96196e1afc..06395272b2 100644 --- a/backend/src/services/kms/kms-fns.ts +++ b/backend/src/services/kms/kms-fns.ts @@ -1,5 +1,7 @@ import { SymmetricEncryption } from "@app/lib/crypto/cipher"; +export const KMS_ROOT_CONFIG_UUID = "00000000-0000-0000-0000-000000000000"; + export const getByteLengthForAlgorithm = (encryptionAlgorithm: SymmetricEncryption) => { switch (encryptionAlgorithm) { case SymmetricEncryption.AES_GCM_128: diff --git a/backend/src/services/kms/kms-service.ts b/backend/src/services/kms/kms-service.ts index 1b5c282a65..007d33e617 100644 --- a/backend/src/services/kms/kms-service.ts +++ b/backend/src/services/kms/kms-service.ts @@ -2,13 +2,14 @@ import slugify from "@sindresorhus/slugify"; import { Knex } from "knex"; import { z } from "zod"; -import { KmsKeysSchema } from "@app/db/schemas"; +import { KmsKeysSchema, TKmsRootConfig } from "@app/db/schemas"; import { AwsKmsProviderFactory } from "@app/ee/services/external-kms/providers/aws-kms"; import { ExternalKmsAwsSchema, KmsProviders, TExternalKmsProviderFns } from "@app/ee/services/external-kms/providers/model"; +import { THsmServiceFactory } from "@app/ee/services/hsm/hsm-service"; import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; import { randomSecureBytes } from "@app/lib/crypto"; @@ -17,7 +18,7 @@ import { generateHash } from "@app/lib/crypto/encryption"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { alphaNumericNanoId } from "@app/lib/nanoid"; -import { getByteLengthForAlgorithm } from "@app/services/kms/kms-fns"; +import { getByteLengthForAlgorithm, KMS_ROOT_CONFIG_UUID } from "@app/services/kms/kms-fns"; import { TOrgDALFactory } from "../org/org-dal"; import { TProjectDALFactory } from "../project/project-dal"; @@ -27,6 +28,7 @@ import { TKmsRootConfigDALFactory } from "./kms-root-config-dal"; import { KmsDataKey, KmsType, + RootKeyEncryptionStrategy, TDecryptWithKeyDTO, TDecryptWithKmsDTO, TEncryptionWithKeyDTO, @@ -40,15 +42,14 @@ type TKmsServiceFactoryDep = { kmsDAL: TKmsKeyDALFactory; projectDAL: Pick; orgDAL: Pick; - kmsRootConfigDAL: Pick; + kmsRootConfigDAL: Pick; keyStore: Pick; internalKmsDAL: Pick; + hsmService: THsmServiceFactory; }; export type TKmsServiceFactory = ReturnType; -const KMS_ROOT_CONFIG_UUID = "00000000-0000-0000-0000-000000000000"; - const KMS_ROOT_CREATION_WAIT_KEY = "wait_till_ready_kms_root_key"; const KMS_ROOT_CREATION_WAIT_TIME = 10; @@ -63,7 +64,8 @@ export const kmsServiceFactory = ({ keyStore, internalKmsDAL, orgDAL, - projectDAL + projectDAL, + hsmService }: TKmsServiceFactoryDep) => { let ROOT_ENCRYPTION_KEY = Buffer.alloc(0); @@ -610,6 +612,65 @@ export const kmsServiceFactory = ({ } }; + const $getBasicEncryptionKey = () => { + const appCfg = getConfig(); + + const encryptionKey = appCfg.ENCRYPTION_KEY || appCfg.ROOT_ENCRYPTION_KEY; + const isBase64 = !appCfg.ENCRYPTION_KEY; + if (!encryptionKey) + throw new Error( + "Root encryption key not found for KMS service. Did you set the ENCRYPTION_KEY or ROOT_ENCRYPTION_KEY environment variables?" + ); + + const encryptionKeyBuffer = Buffer.from(encryptionKey, isBase64 ? "base64" : "utf8"); + + return encryptionKeyBuffer; + }; + + const $decryptRootKey = async (kmsRootConfig: TKmsRootConfig) => { + // case 1: root key is encrypted with HSM + if (kmsRootConfig.encryptionStrategy === RootKeyEncryptionStrategy.HSM) { + const hsmIsActive = await hsmService.isActive(); + if (!hsmIsActive) { + throw new Error("Unable to decrypt root KMS key. HSM service is inactive. Did you configure the HSM?"); + } + + const decryptedKey = await hsmService.decrypt(kmsRootConfig.encryptedRootKey); + return decryptedKey; + } + + // case 2: root key is encrypted with software encryption + if (kmsRootConfig.encryptionStrategy === RootKeyEncryptionStrategy.Software) { + const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); + const encryptionKeyBuffer = $getBasicEncryptionKey(); + + return cipher.decrypt(kmsRootConfig.encryptedRootKey, encryptionKeyBuffer); + } + + throw new Error(`Invalid root key encryption strategy: ${kmsRootConfig.encryptionStrategy}`); + }; + + const $encryptRootKey = async (plainKeyBuffer: Buffer, strategy: RootKeyEncryptionStrategy) => { + if (strategy === RootKeyEncryptionStrategy.HSM) { + const hsmIsActive = await hsmService.isActive(); + if (!hsmIsActive) { + throw new Error("Unable to encrypt root KMS key. HSM service is inactive. Did you configure the HSM?"); + } + const encrypted = await hsmService.encrypt(plainKeyBuffer); + return encrypted; + } + + if (strategy === RootKeyEncryptionStrategy.Software) { + const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); + const encryptionKeyBuffer = $getBasicEncryptionKey(); + + return cipher.encrypt(plainKeyBuffer, encryptionKeyBuffer); + } + + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + throw new Error(`Invalid root key encryption strategy: ${strategy}`); + }; + // by keeping the decrypted data key in inner scope // none of the entities outside can interact directly or expose the data key // NOTICE: If changing here update migrations/utils/kms @@ -771,7 +832,6 @@ export const kmsServiceFactory = ({ }, tx ); - return kmsDAL.findByIdWithAssociatedKms(key.id, tx); }); @@ -794,14 +854,6 @@ export const kmsServiceFactory = ({ // akhilmhdh: a copy of this is made in migrations/utils/kms const startService = async () => { - const appCfg = getConfig(); - // This will switch to a seal process and HMS flow in future - const encryptionKey = appCfg.ENCRYPTION_KEY || appCfg.ROOT_ENCRYPTION_KEY; - // if root key its base64 encoded - 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 lock = await keyStore.acquireLock([`KMS_ROOT_CFG_LOCK`], 3000, { retryCount: 3 }).catch(() => null); if (!lock) { await keyStore.waitTillReady({ @@ -813,31 +865,69 @@ export const kmsServiceFactory = ({ // check if KMS root key was already generated and saved in DB const kmsRootConfig = await kmsRootConfigDAL.findById(KMS_ROOT_CONFIG_UUID); - const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); + + // case 1: a root key already exists in the DB 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 + logger.info(`KMS: Encrypted ROOT Key found from DB. Decrypting. [strategy=${kmsRootConfig.encryptionStrategy}]`); + + const decryptedRootKey = await $decryptRootKey(kmsRootConfig); + + // 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; return; } - logger.info("KMS: Generating ROOT Key"); + // case 2: no config is found, so we create a new root key with basic encryption + logger.info("KMS: Generating new ROOT Key"); const newRootKey = randomSecureBytes(32); - const encryptedRootKey = cipher.encrypt(newRootKey, encryptionKeyBuffer); - // @ts-expect-error id is kept as fixed for idempotence and to avoid race condition - await kmsRootConfigDAL.create({ encryptedRootKey, id: KMS_ROOT_CONFIG_UUID }); + const encryptedRootKey = await $encryptRootKey(newRootKey, RootKeyEncryptionStrategy.Software).catch((err) => { + logger.error({ hsmEnabled: hsmService.isActive() }, "KMS: Failed to encrypt ROOT Key"); + throw err; + }); + + await kmsRootConfigDAL.create({ + // @ts-expect-error id is kept as fixed for idempotence and to avoid race condition + id: KMS_ROOT_CONFIG_UUID, + encryptedRootKey, + encryptionStrategy: RootKeyEncryptionStrategy.Software + }); - // set the flag so that other instancen nodes can start + // 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: Saved and loaded ROOT Key into memory"); if (lock) await lock.release(); ROOT_ENCRYPTION_KEY = newRootKey; }; + const updateEncryptionStrategy = async (strategy: RootKeyEncryptionStrategy) => { + const kmsRootConfig = await kmsRootConfigDAL.findById(KMS_ROOT_CONFIG_UUID); + if (!kmsRootConfig) { + throw new NotFoundError({ message: "KMS root config not found" }); + } + + if (kmsRootConfig.encryptionStrategy === strategy) { + return; + } + + const decryptedRootKey = await $decryptRootKey(kmsRootConfig); + const encryptedRootKey = await $encryptRootKey(decryptedRootKey, strategy); + + if (!encryptedRootKey) { + logger.error("KMS: Failed to re-encrypt ROOT Key with selected strategy"); + throw new Error("Failed to re-encrypt ROOT Key with selected strategy"); + } + + await kmsRootConfigDAL.updateById(KMS_ROOT_CONFIG_UUID, { + encryptedRootKey, + encryptionStrategy: strategy + }); + + ROOT_ENCRYPTION_KEY = decryptedRootKey; + }; + return { startService, generateKmsKey, @@ -849,6 +939,7 @@ export const kmsServiceFactory = ({ encryptWithRootKey, decryptWithRootKey, getOrgKmsKeyId, + updateEncryptionStrategy, getProjectSecretManagerKmsKeyId, updateProjectSecretManagerKmsKey, getProjectKeyBackup, diff --git a/backend/src/services/kms/kms-types.ts b/backend/src/services/kms/kms-types.ts index 5d5b77a093..f655d4b5d1 100644 --- a/backend/src/services/kms/kms-types.ts +++ b/backend/src/services/kms/kms-types.ts @@ -56,3 +56,8 @@ export type TUpdateProjectSecretManagerKmsKeyDTO = { projectId: string; kms: { type: KmsType.Internal } | { type: KmsType.External; kmsId: string }; }; + +export enum RootKeyEncryptionStrategy { + Software = "SOFTWARE", + HSM = "HSM" +} diff --git a/backend/src/services/super-admin/super-admin-service.ts b/backend/src/services/super-admin/super-admin-service.ts index 12c25de913..8ef998ac3c 100644 --- a/backend/src/services/super-admin/super-admin-service.ts +++ b/backend/src/services/super-admin/super-admin-service.ts @@ -10,7 +10,10 @@ import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { TAuthLoginFactory } from "../auth/auth-login-service"; import { AuthMethod } from "../auth/auth-type"; +import { KMS_ROOT_CONFIG_UUID } from "../kms/kms-fns"; +import { TKmsRootConfigDALFactory } from "../kms/kms-root-config-dal"; import { TKmsServiceFactory } from "../kms/kms-service"; +import { RootKeyEncryptionStrategy } from "../kms/kms-types"; import { TOrgServiceFactory } from "../org/org-service"; import { TUserDALFactory } from "../user/user-dal"; import { TSuperAdminDALFactory } from "./super-admin-dal"; @@ -20,7 +23,8 @@ type TSuperAdminServiceFactoryDep = { serverCfgDAL: TSuperAdminDALFactory; userDAL: TUserDALFactory; authService: Pick; - kmsService: Pick; + kmsService: Pick; + kmsRootConfigDAL: TKmsRootConfigDALFactory; orgService: Pick; keyStore: Pick; licenseService: Pick; @@ -47,6 +51,7 @@ export const superAdminServiceFactory = ({ authService, orgService, keyStore, + kmsRootConfigDAL, kmsService, licenseService }: TSuperAdminServiceFactoryDep) => { @@ -288,12 +293,70 @@ export const superAdminServiceFactory = ({ }; }; + const getConfiguredEncryptionStrategies = async () => { + const appCfg = getConfig(); + + const kmsRootCfg = await kmsRootConfigDAL.findById(KMS_ROOT_CONFIG_UUID); + + if (!kmsRootCfg) { + throw new NotFoundError({ name: "KmsRootConfig", message: "KMS root configuration not found" }); + } + + const selectedStrategy = kmsRootCfg.encryptionStrategy; + const enabledStrategies: { enabled: boolean; strategy: RootKeyEncryptionStrategy }[] = []; + + if (appCfg.ROOT_ENCRYPTION_KEY || appCfg.ENCRYPTION_KEY) { + const basicStrategy = RootKeyEncryptionStrategy.Software; + + enabledStrategies.push({ + enabled: selectedStrategy === basicStrategy, + strategy: basicStrategy + }); + } + if (appCfg.isHsmConfigured) { + const hsmStrategy = RootKeyEncryptionStrategy.HSM; + + enabledStrategies.push({ + enabled: selectedStrategy === hsmStrategy, + strategy: hsmStrategy + }); + } + + return { + strategies: enabledStrategies + }; + }; + + const updateRootEncryptionStrategy = async (strategy: RootKeyEncryptionStrategy) => { + if (!licenseService.onPremFeatures.hsm) { + throw new BadRequestError({ + message: "Failed to update encryption strategy due to plan restriction. Upgrade to Infisical's Enterprise plan." + }); + } + + const configuredStrategies = await getConfiguredEncryptionStrategies(); + + const foundStrategy = configuredStrategies.strategies.find((s) => s.strategy === strategy); + + if (!foundStrategy) { + throw new BadRequestError({ message: "Invalid encryption strategy" }); + } + + if (foundStrategy.enabled) { + throw new BadRequestError({ message: "The selected encryption strategy is already enabled" }); + } + + await kmsService.updateEncryptionStrategy(strategy); + }; + return { initServerCfg, updateServerCfg, adminSignUp, getUsers, deleteUser, - getAdminSlackConfig + getAdminSlackConfig, + updateRootEncryptionStrategy, + getConfiguredEncryptionStrategies }; }; diff --git a/cli/packages/api/model.go b/cli/packages/api/model.go index f96c937099..adf7a814d3 100644 --- a/cli/packages/api/model.go +++ b/cli/packages/api/model.go @@ -136,8 +136,8 @@ type GetOrganizationsResponse struct { } type SelectOrganizationResponse struct { - Token string `json:"token"` - MfaEnabled bool `json:"isMfaEnabled"` + Token string `json:"token"` + MfaEnabled bool `json:"isMfaEnabled"` } type SelectOrganizationRequest struct { diff --git a/frontend/src/hooks/api/admin/index.ts b/frontend/src/hooks/api/admin/index.ts index 658feeaa31..5405878c7c 100644 --- a/frontend/src/hooks/api/admin/index.ts +++ b/frontend/src/hooks/api/admin/index.ts @@ -2,6 +2,12 @@ export { useAdminDeleteUser, useCreateAdminUser, useUpdateAdminSlackConfig, - useUpdateServerConfig + useUpdateServerConfig, + useUpdateServerEncryptionStrategy } from "./mutation"; -export { useAdminGetUsers, useGetAdminSlackConfig, useGetServerConfig } from "./queries"; +export { + useAdminGetUsers, + useGetAdminSlackConfig, + useGetServerConfig, + useGetServerRootKmsEncryptionDetails +} from "./queries"; diff --git a/frontend/src/hooks/api/admin/mutation.ts b/frontend/src/hooks/api/admin/mutation.ts index 9ab51d250b..6cd13050ee 100644 --- a/frontend/src/hooks/api/admin/mutation.ts +++ b/frontend/src/hooks/api/admin/mutation.ts @@ -7,6 +7,7 @@ import { User } from "../users/types"; import { adminQueryKeys, adminStandaloneKeys } from "./queries"; import { AdminSlackConfig, + RootKeyEncryptionStrategy, TCreateAdminUserDTO, TServerConfig, TUpdateAdminSlackConfigDTO @@ -85,3 +86,15 @@ export const useUpdateAdminSlackConfig = () => { } }); }; + +export const useUpdateServerEncryptionStrategy = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (strategy: RootKeyEncryptionStrategy) => { + await apiRequest.patch("/api/v1/admin/encryption-strategies", { strategy }); + }, + onSuccess: () => { + queryClient.invalidateQueries(adminQueryKeys.getServerEncryptionStrategies()); + } + }); +}; diff --git a/frontend/src/hooks/api/admin/queries.ts b/frontend/src/hooks/api/admin/queries.ts index 653b124d77..a1d32bce35 100644 --- a/frontend/src/hooks/api/admin/queries.ts +++ b/frontend/src/hooks/api/admin/queries.ts @@ -3,7 +3,12 @@ import { useInfiniteQuery, useQuery, UseQueryOptions } from "@tanstack/react-que import { apiRequest } from "@app/config/request"; import { User } from "../types"; -import { AdminGetUsersFilters, AdminSlackConfig, TServerConfig } from "./types"; +import { + AdminGetUsersFilters, + AdminSlackConfig, + TGetServerRootKmsEncryptionDetails, + TServerConfig +} from "./types"; export const adminStandaloneKeys = { getUsers: "get-users" @@ -12,7 +17,8 @@ export const adminStandaloneKeys = { export const adminQueryKeys = { serverConfig: () => ["server-config"] as const, getUsers: (filters: AdminGetUsersFilters) => [adminStandaloneKeys.getUsers, { filters }] as const, - getAdminSlackConfig: () => ["admin-slack-config"] as const + getAdminSlackConfig: () => ["admin-slack-config"] as const, + getServerEncryptionStrategies: () => ["server-encryption-strategies"] as const }; const fetchServerConfig = async () => { @@ -61,8 +67,8 @@ export const useAdminGetUsers = (filters: AdminGetUsersFilters) => { }); }; -export const useGetAdminSlackConfig = () => - useQuery({ +export const useGetAdminSlackConfig = () => { + return useQuery({ queryKey: adminQueryKeys.getAdminSlackConfig(), queryFn: async () => { const { data } = await apiRequest.get( @@ -72,3 +78,17 @@ export const useGetAdminSlackConfig = () => return data; } }); +}; + +export const useGetServerRootKmsEncryptionDetails = () => { + return useQuery({ + queryKey: adminQueryKeys.getServerEncryptionStrategies(), + queryFn: async () => { + const { data } = await apiRequest.get( + "/api/v1/admin/encryption-strategies" + ); + + return data; + } + }); +}; diff --git a/frontend/src/hooks/api/admin/types.ts b/frontend/src/hooks/api/admin/types.ts index c95912b5e5..60fa3ab988 100644 --- a/frontend/src/hooks/api/admin/types.ts +++ b/frontend/src/hooks/api/admin/types.ts @@ -54,3 +54,15 @@ export type AdminSlackConfig = { clientId: string; clientSecret: string; }; + +export type TGetServerRootKmsEncryptionDetails = { + strategies: { + strategy: RootKeyEncryptionStrategy; + enabled: boolean; + }[]; +}; + +export enum RootKeyEncryptionStrategy { + Software = "SOFTWARE", + HSM = "HSM" +} diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index 1ab01ef05b..b1c4e224d2 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -23,6 +23,7 @@ export type SubscriptionPlan = { workspacesUsed: number; environmentLimit: number; samlSSO: boolean; + hsm: boolean; oidcSSO: boolean; scim: boolean; ldap: boolean; diff --git a/frontend/src/views/admin/DashboardPage/DashboardPage.tsx b/frontend/src/views/admin/DashboardPage/DashboardPage.tsx index d682abf2b0..ac87596ae2 100644 --- a/frontend/src/views/admin/DashboardPage/DashboardPage.tsx +++ b/frontend/src/views/admin/DashboardPage/DashboardPage.tsx @@ -22,15 +22,21 @@ import { Tabs } from "@app/components/v2"; import { useOrganization, useServerConfig, useUser } from "@app/context"; -import { useGetOrganizations, useUpdateServerConfig } from "@app/hooks/api"; +import { + useGetOrganizations, + useGetServerRootKmsEncryptionDetails, + useUpdateServerConfig +} from "@app/hooks/api"; import { AuthPanel } from "./AuthPanel"; +import { EncryptionPanel } from "./EncryptionPanel"; import { IntegrationPanel } from "./IntegrationPanel"; import { RateLimitPanel } from "./RateLimitPanel"; import { UserPanel } from "./UserPanel"; enum TabSections { Settings = "settings", + Encryption = "encryption", Auth = "auth", RateLimit = "rate-limit", Integrations = "integrations", @@ -55,6 +61,7 @@ type TDashboardForm = z.infer; export const AdminDashboardPage = () => { const router = useRouter(); const data = useServerConfig(); + const { data: serverRootKmsDetails } = useGetServerRootKmsEncryptionDetails(); const { config } = data; const { @@ -137,6 +144,7 @@ export const AdminDashboardPage = () => {
General + Encryption Authentication Rate Limit Integrations @@ -321,6 +329,9 @@ export const AdminDashboardPage = () => { + + + diff --git a/frontend/src/views/admin/DashboardPage/EncryptionPanel.tsx b/frontend/src/views/admin/DashboardPage/EncryptionPanel.tsx new file mode 100644 index 0000000000..0383423f89 --- /dev/null +++ b/frontend/src/views/admin/DashboardPage/EncryptionPanel.tsx @@ -0,0 +1,137 @@ +import { useCallback } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, Select, SelectItem, UpgradePlanModal } from "@app/components/v2"; +import { useSubscription } from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { useUpdateServerEncryptionStrategy } from "@app/hooks/api"; +import { + RootKeyEncryptionStrategy, + TGetServerRootKmsEncryptionDetails +} from "@app/hooks/api/admin/types"; + +const formSchema = z.object({ + encryptionStrategy: z.nativeEnum(RootKeyEncryptionStrategy) +}); + +const strategies: Record = { + [RootKeyEncryptionStrategy.Software]: "Software-based Encryption", + [RootKeyEncryptionStrategy.HSM]: "Hardware Security Module (HSM)" +}; + +type TForm = z.infer; + +type Props = { + rootKmsDetails?: TGetServerRootKmsEncryptionDetails; +}; + +export const EncryptionPanel = ({ rootKmsDetails }: Props) => { + const { mutateAsync: updateEncryptionStrategy } = useUpdateServerEncryptionStrategy(); + const { subscription } = useSubscription(); + + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"] as const); + + const { + control, + handleSubmit, + formState: { isSubmitting, isDirty } + } = useForm({ + resolver: zodResolver(formSchema), + values: { + encryptionStrategy: + rootKmsDetails?.strategies?.find((s) => s.enabled)?.strategy ?? + RootKeyEncryptionStrategy.Software + } + }); + + const onSubmit = useCallback(async (formData: TForm) => { + if (!subscription) return; + + if (!subscription.hsm) { + handlePopUpOpen("upgradePlan", { + description: "Hardware Security Module's (HSM's), are only available on Enterprise plans." + }); + return; + } + + try { + await updateEncryptionStrategy(formData.encryptionStrategy); + + createNotification({ + type: "success", + text: "Encryption strategy updated successfully" + }); + } catch { + createNotification({ + type: "error", + text: "Failed to update encryption strategy" + }); + } + }, []); + + return ( + <> +
+
+
+
+ KMS Encryption Strategy +
+
+
+ Select which type of encryption strategy you want to use for your KMS root key. HSM is + supported on Enterprise plans. +
+ + {!!rootKmsDetails && ( + ( + + + + )} + /> + )} +
+ + +
+ handlePopUpToggle("upgradePlan", isOpen)} + text={(popUp.upgradePlan?.data as { description: string })?.description} + /> + + ); +}; diff --git a/package-lock.json b/package-lock.json index 8e44e829eb..b57a8437f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,8 @@ "name": "infisical", "license": "ISC", "dependencies": { - "@radix-ui/react-radio-group": "^1.1.3" + "@radix-ui/react-radio-group": "^1.1.3", + "secrets.js-grempe": "^2.0.0" }, "devDependencies": { "@types/uuid": "^9.0.7", @@ -1392,6 +1393,12 @@ "loose-envify": "^1.1.0" } }, + "node_modules/secrets.js-grempe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/secrets.js-grempe/-/secrets.js-grempe-2.0.0.tgz", + "integrity": "sha512-4xkOIaDAg998dTFXZUJTOoVbdLHfB818SMeLJ69ABccgGEKokxsoRFupAFfAImloUSKv4QUGNMgKVbKMf6z0Ug==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -2457,6 +2464,11 @@ "loose-envify": "^1.1.0" } }, + "secrets.js-grempe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/secrets.js-grempe/-/secrets.js-grempe-2.0.0.tgz", + "integrity": "sha512-4xkOIaDAg998dTFXZUJTOoVbdLHfB818SMeLJ69ABccgGEKokxsoRFupAFfAImloUSKv4QUGNMgKVbKMf6z0Ug==" + }, "shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/package.json b/package.json index 2ecb7217ee..10ec57eba4 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "husky": "^8.0.3" }, "dependencies": { - "@radix-ui/react-radio-group": "^1.1.3" + "@radix-ui/react-radio-group": "^1.1.3", + "secrets.js-grempe": "^2.0.0" } }