diff --git a/.github/actions/build-docker/action.yml b/.github/actions/build-docker/action.yml index 2d7b1fa736a18..264fec9d129c4 100644 --- a/.github/actions/build-docker/action.yml +++ b/.github/actions/build-docker/action.yml @@ -8,6 +8,12 @@ inputs: CR_PAT: required: true description: 'GitHub Container Registry Personal Access Token' + DOCKER_USER: + required: false + description: 'DockerHub username (required for FIPS builds to pull private base image)' + DOCKER_PASS: + required: false + description: 'DockerHub password (required for FIPS builds to pull private base image)' deno-version: required: true description: 'Deno version' @@ -28,7 +34,7 @@ inputs: default: 'true' type: required: false - description: 'production or coverage' + description: 'production, coverage, or fips' default: 'coverage' runs: @@ -43,11 +49,21 @@ runs: username: ${{ inputs.CR_USER }} password: ${{ inputs.CR_PAT }} + - name: Login to DockerHub + # FIPS base image (rocketchat/dhi-node) lives in a private DockerHub repo and requires auth to pull. + if: inputs.type == 'fips' && github.actor != 'dependabot[bot]' && (github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop') + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + with: + username: ${{ inputs.DOCKER_USER }} + password: ${{ inputs.DOCKER_PASS }} + - name: Restore meteor build if: inputs.service == 'rocketchat' uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: - name: build-${{ inputs.type }} + # Temporary: rocketchat fips reuses existing Meteor artifacts until build-fips exists. + # PR/merge queue runs only produce build-coverage, while release/develop produce build-production. + name: build-${{ inputs.type == 'fips' && ((github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production') || inputs.type }} path: /tmp/build - name: Unpack meteor build @@ -85,6 +101,10 @@ runs: GITHUB_REF: ${{ github.ref }} run: | set -o xtrace + compose_fips_override='' + if [[ "$INPUT_TYPE" == 'fips' ]]; then + compose_fips_override='-f docker-compose-ci.fips.yml' + fi # Removes unnecessary swc cores and sharp binaries to reduce image size swc_arch='x64' @@ -109,10 +129,10 @@ runs: fi # Get image name from docker-compose-ci.yml since rocketchat image is different from service name (rocket.chat) - IMAGE=$(docker compose -f docker-compose-ci.yml config --format json 2>/dev/null | jq -r --arg s "$INPUT_SERVICE" '.services[$s].image') + IMAGE=$(docker compose -f docker-compose-ci.yml $compose_fips_override config --format json 2>/dev/null | jq -r --arg s "$INPUT_SERVICE" '.services[$s].image') docker buildx bake \ - -f docker-compose-ci.yml \ + -f docker-compose-ci.yml $compose_fips_override \ ${LOAD_OR_PUSH} \ --allow=fs.read=/tmp/build \ --set "*.tags+=${IMAGE}-gha-run-${GITHUB_RUN_ID}" \ @@ -133,6 +153,8 @@ runs: SERVICE_SUFFIX='' if [[ "$INPUT_SERVICE" == 'rocketchat' && "$INPUT_TYPE" == 'coverage' ]] && [[ "$GITHUB_EVENT_NAME" == 'release' || "$GITHUB_REF" == 'refs/heads/develop' ]]; then SERVICE_SUFFIX='-cov' + elif [[ "$INPUT_TYPE" == 'fips' ]]; then + SERVICE_SUFFIX='-fips' fi mkdir -p "/tmp/manifests/${INPUT_SERVICE}${SERVICE_SUFFIX}/${INPUT_ARCH}" @@ -160,9 +182,13 @@ runs: TYPE: ${{ inputs.type }} run: | set -o xtrace + compose_fips_override='' + if [[ "$TYPE" == 'fips' ]]; then + compose_fips_override='-f docker-compose-ci.fips.yml' + fi # Get image name from docker-compose-ci.yml - IMAGE=$(docker compose -f docker-compose-ci.yml config --format json 2>/dev/null | jq -r --arg s "$SERVICE" '.services[$s].image') + IMAGE=$(docker compose -f docker-compose-ci.yml $compose_fips_override config --format json 2>/dev/null | jq -r --arg s "$SERVICE" '.services[$s].image') # Create directory for image archives mkdir -p /tmp/docker-images diff --git a/.github/workflows/ci-test-e2e.yml b/.github/workflows/ci-test-e2e.yml index c8421db957b60..d8e7a95c81e8c 100644 --- a/.github/workflows/ci-test-e2e.yml +++ b/.github/workflows/ci-test-e2e.yml @@ -134,7 +134,7 @@ jobs: uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 if: github.event.pull_request.head.repo.full_name != github.repository && github.event_name != 'release' && github.ref != 'refs/heads/develop' with: - pattern: ${{ inputs.release == 'ce' && 'docker-image-rocketchat-amd64-coverage' || 'docker-image-*-amd64-coverage' }} + pattern: ${{ inputs.release == 'ce' && 'docker-image-rocketchat-amd64-coverage' || (inputs.release == 'fips' && 'docker-image-*-amd64-fips' || 'docker-image-*-amd64-coverage') }} path: /tmp/docker-images merge-multiple: true @@ -187,20 +187,22 @@ jobs: DEBUG_LOG_LEVEL=${DEBUG_LOG_LEVEL:-0} docker compose -f docker-compose-ci.yml up -d rocketchat --wait - name: Start containers for EE - if: inputs.release == 'ee' + if: inputs.release == 'ee' || inputs.release == 'fips' env: ENTERPRISE_LICENSE: ${{ inputs.enterprise-license }} TRANSPORTER: ${{ inputs.transporter }} COMPOSE_PROFILES: ${{ inputs.type == 'api' && 'api' || '' }} TEST_MODE: ${{ (inputs.type == 'api' || inputs.type == 'api-livechat') && 'api' || 'true' }} + FIPS_OVERRIDE: ${{ inputs.release == 'fips' && '-f docker-compose-ci.fips.yml' || '' }} run: | - DEBUG_LOG_LEVEL=${DEBUG_LOG_LEVEL:-0} docker compose -f docker-compose-ci.yml up -d --wait + read -r -a fips_override <<< "$FIPS_OVERRIDE" + DEBUG_LOG_LEVEL=${DEBUG_LOG_LEVEL:-0} docker compose -f docker-compose-ci.yml "${fips_override[@]}" up -d --wait - uses: ./.github/actions/setup-playwright if: inputs.type == 'ui' - name: Wait services to start up - if: inputs.release == 'ee' + if: inputs.release == 'ee' || inputs.release == 'fips' run: | docker ps @@ -218,7 +220,7 @@ jobs: working-directory: ./apps/meteor env: WEBHOOK_TEST_URL: 'http://httpbin' - IS_EE: ${{ inputs.release == 'ee' && 'true' || '' }} + IS_EE: ${{ (inputs.release == 'ee' || inputs.release == 'fips') && 'true' || '' }} run: | set -o xtrace @@ -234,7 +236,7 @@ jobs: working-directory: ./apps/meteor env: WEBHOOK_TEST_URL: 'http://httpbin' - IS_EE: ${{ inputs.release == 'ee' && 'true' || '' }} + IS_EE: ${{ (inputs.release == 'ee' || inputs.release == 'fips') && 'true' || '' }} run: | set -o xtrace @@ -249,7 +251,7 @@ jobs: if: inputs.type == 'ui' env: E2E_COVERAGE: ${{ inputs.coverage == matrix.mongodb-version && 'true' || '' }} - IS_EE: ${{ inputs.release == 'ee' && 'true' || '' }} + IS_EE: ${{ (inputs.release == 'ee' || inputs.release == 'fips') && 'true' || '' }} REPORTER_ROCKETCHAT_API_KEY: ${{ secrets.REPORTER_ROCKETCHAT_API_KEY }} REPORTER_ROCKETCHAT_URL: ${{ secrets.REPORTER_ROCKETCHAT_URL }} REPORTER_JIRA_ROCKETCHAT_API_KEY: ${{ secrets.REPORTER_JIRA_ROCKETCHAT_API_KEY }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 596ebed8c9002..359b4491e1be1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -328,6 +328,28 @@ jobs: - arch: arm64 service: [rocketchat] type: coverage + # FIPS images: amd64 only, includes all microservices + - arch: amd64 + service: [authorization-service] + type: fips + - arch: amd64 + service: [queue-worker-service] + type: fips + - arch: amd64 + service: [ddp-streamer-service] + type: fips + - arch: amd64 + service: [account-service] + type: fips + - arch: amd64 + service: [presence-service] + type: fips + - arch: amd64 + service: [omnichannel-transcript-service] + type: fips + - arch: amd64 + service: [rocketchat] + type: fips steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -344,6 +366,8 @@ jobs: with: CR_USER: ${{ secrets.CR_USER }} CR_PAT: ${{ secrets.CR_PAT }} + DOCKER_USER: ${{ secrets.DOCKER_USER }} + DOCKER_PASS: ${{ secrets.DOCKER_PASS }} deno-version: ${{ needs.release-versions.outputs.deno-version }} arch: ${{ matrix.arch }} service: ${{ matrix.service[0] }} @@ -358,6 +382,8 @@ jobs: with: CR_USER: ${{ secrets.CR_USER }} CR_PAT: ${{ secrets.CR_PAT }} + DOCKER_USER: ${{ secrets.DOCKER_USER }} + DOCKER_PASS: ${{ secrets.DOCKER_PASS }} deno-version: ${{ needs.release-versions.outputs.deno-version }} arch: ${{ matrix.arch }} service: ${{ matrix.service[1] }} @@ -373,6 +399,8 @@ jobs: with: CR_USER: ${{ secrets.CR_USER }} CR_PAT: ${{ secrets.CR_PAT }} + DOCKER_USER: ${{ secrets.DOCKER_USER }} + DOCKER_PASS: ${{ secrets.DOCKER_PASS }} deno-version: ${{ needs.release-versions.outputs.deno-version }} arch: ${{ matrix.arch }} service: ${{ matrix.service[2] }} @@ -388,6 +416,8 @@ jobs: with: CR_USER: ${{ secrets.CR_USER }} CR_PAT: ${{ secrets.CR_PAT }} + DOCKER_USER: ${{ secrets.DOCKER_USER }} + DOCKER_PASS: ${{ secrets.DOCKER_PASS }} deno-version: ${{ needs.release-versions.outputs.deno-version }} arch: ${{ matrix.arch }} service: ${{ matrix.service[3] }} @@ -410,6 +440,7 @@ jobs: with: sparse-checkout: | docker-compose-ci.yml + docker-compose-ci.fips.yml sparse-checkout-cone-mode: false ref: ${{ github.ref }} @@ -452,6 +483,9 @@ jobs: # Get image name from docker-compose-ci.yml since rocketchat image is different from service name (rocket.chat) if [ "$service" == "rocketchat-cov" ]; then IMAGE=$(docker compose -f docker-compose-ci.yml config --format json 2>/dev/null | jq -r --arg s "rocketchat" '.services[$s].image')-cov + elif [[ "$service" == *"-fips" ]]; then + base_service="${service%-fips}" + IMAGE=$(docker compose -f docker-compose-ci.yml config --format json 2>/dev/null | jq -r --arg s "$base_service" '.services[$s].image')-fips else IMAGE=$(docker compose -f docker-compose-ci.yml config --format json 2>/dev/null | jq -r --arg s "$service" '.services[$s].image') fi @@ -679,6 +713,73 @@ jobs: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} REPORTER_JIRA_ROCKETCHAT_API_KEY: ${{ secrets.REPORTER_JIRA_ROCKETCHAT_API_KEY }} + test-api-fips: + name: 🔨 Test API (FIPS) + needs: [checks, build-gh-docker-publish, release-versions] + + uses: ./.github/workflows/ci-test-e2e.yml + with: + type: api + release: fips + transporter: 'nats://nats:4222' + enterprise-license: ${{ needs.release-versions.outputs.enterprise-license }} + mongodb-version: "['8.0']" + node-version: ${{ needs.release-versions.outputs.node-version }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} + lowercase-repo: ${{ needs.release-versions.outputs.lowercase-repo }} + gh-docker-tag: ${{ needs.release-versions.outputs.gh-docker-tag }} + secrets: + CR_USER: ${{ secrets.CR_USER }} + CR_PAT: ${{ secrets.CR_PAT }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + + test-api-livechat-fips: + name: 🔨 Test API Livechat (FIPS) + needs: [checks, build-gh-docker-publish, release-versions] + + uses: ./.github/workflows/ci-test-e2e.yml + with: + type: api-livechat + release: fips + transporter: 'nats://nats:4222' + enterprise-license: ${{ needs.release-versions.outputs.enterprise-license }} + mongodb-version: "['8.0']" + node-version: ${{ needs.release-versions.outputs.node-version }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} + lowercase-repo: ${{ needs.release-versions.outputs.lowercase-repo }} + gh-docker-tag: ${{ needs.release-versions.outputs.gh-docker-tag }} + secrets: + CR_USER: ${{ secrets.CR_USER }} + CR_PAT: ${{ secrets.CR_PAT }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + + test-ui-fips: + name: 🔨 Test UI (FIPS) + needs: [checks, build-gh-docker-publish, release-versions] + + uses: ./.github/workflows/ci-test-e2e.yml + with: + type: ui + release: fips + transporter: 'nats://nats:4222' + enterprise-license: ${{ needs.release-versions.outputs.enterprise-license }} + shard: '[1, 2, 3, 4, 5]' + total-shard: 5 + mongodb-version: "['8.0']" + node-version: ${{ needs.release-versions.outputs.node-version }} + deno-version: ${{ needs.release-versions.outputs.deno-version }} + lowercase-repo: ${{ needs.release-versions.outputs.lowercase-repo }} + gh-docker-tag: ${{ needs.release-versions.outputs.gh-docker-tag }} + retries: ${{ (github.event_name == 'release' || github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/master') && 2 || 0 }} + secrets: + CR_USER: ${{ secrets.CR_USER }} + CR_PAT: ${{ secrets.CR_PAT }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + QASE_API_TOKEN: ${{ secrets.QASE_API_TOKEN }} + REPORTER_ROCKETCHAT_API_KEY: ${{ secrets.REPORTER_ROCKETCHAT_API_KEY }} + REPORTER_ROCKETCHAT_URL: ${{ secrets.REPORTER_ROCKETCHAT_URL }} + REPORTER_JIRA_ROCKETCHAT_API_KEY: ${{ secrets.REPORTER_JIRA_ROCKETCHAT_API_KEY }} + test-federation-matrix: name: 🔨 Test Federation Matrix needs: [checks, build-gh-docker-publish, packages-build, release-versions] @@ -829,7 +930,7 @@ jobs: tests-done: name: ✅ Tests Done runs-on: ubuntu-24.04-arm - needs: [checks, test-unit, test-api, test-ui, test-api-ee, test-ui-ee, test-api-livechat, test-api-livechat-ee, test-federation-matrix] + needs: [checks, test-unit, test-api, test-ui, test-api-ee, test-ui-ee, test-api-livechat, test-api-livechat-ee, test-api-fips, test-api-livechat-fips, test-ui-fips, test-federation-matrix] if: always() steps: - name: Test finish aggregation @@ -866,6 +967,18 @@ jobs: exit 1 fi + if [[ '${{ needs.test-api-fips.result }}' != 'success' ]]; then + exit 1 + fi + + if [[ '${{ needs.test-api-livechat-fips.result }}' != 'success' ]]; then + exit 1 + fi + + if [[ '${{ needs.test-ui-fips.result }}' != 'success' ]]; then + exit 1 + fi + if [[ '${{ needs.test-federation-matrix.result }}' != 'success' ]]; then exit 1 fi @@ -938,6 +1051,7 @@ jobs: with: sparse-checkout: | docker-compose-ci.yml + docker-compose-ci.fips.yml sparse-checkout-cone-mode: false ref: ${{ github.ref }} @@ -1021,7 +1135,12 @@ jobs: fi # Get image name from docker-compose-ci.yml since rocketchat image is different from service name (rocket.chat) - SRC=$(docker compose -f docker-compose-ci.yml config --format json 2>/dev/null | jq -r --arg s "${service}" '.services[$s].image') + if [[ "$service" == *"-fips" ]]; then + base_service="${service%-fips}" + SRC=$(docker compose -f docker-compose-ci.yml -f docker-compose-ci.fips.yml config --format json 2>/dev/null | jq -r --arg s "$base_service" '.services[$s].image') + else + SRC=$(docker compose -f docker-compose-ci.yml config --format json 2>/dev/null | jq -r --arg s "${service}" '.services[$s].image') + fi DEST_REPO="docker.io/${IMAGE_NAME}" echo "Copying $SRC to ${DEST_REPO}:${PRIMARY}" diff --git a/apps/meteor/.docker/Dockerfile.alpine b/apps/meteor/.docker/Dockerfile.alpine index 2bee7b45f36e3..333f2f026fbf8 100644 --- a/apps/meteor/.docker/Dockerfile.alpine +++ b/apps/meteor/.docker/Dockerfile.alpine @@ -20,7 +20,7 @@ RUN cd /app/bundle/programs/server \ && find /app/bundle/programs/server/npm/node_modules -type f -name '*.map' -delete \ && find /app/bundle/programs/web.browser -type f -name '*.map' -delete -FROM node:22.22.3-alpine3.23 +FROM node:22.22.3-alpine3.23 AS release-standard LABEL maintainer="buildmaster@rocket.chat" @@ -65,3 +65,30 @@ WORKDIR /app/bundle EXPOSE 3000 CMD ["node", "main.js"] + +FROM rocketchat/dhi-node:22.22.3-alpine3.23-fips-dev AS release-fips + +RUN apk add --no-cache deno + +LABEL maintainer="buildmaster@rocket.chat" + +# needs a mongo instance - defaults to container linking with alias 'mongo' +ENV DEPLOY_METHOD=docker \ + NODE_ENV=production \ + MONGO_URL=mongodb://mongo:27017/rocketchat \ + HOME=/tmp \ + PORT=3000 \ + ROOT_URL=http://localhost:3000 \ + Accounts_AvatarStorePath=/app/uploads + +USER node + +COPY --from=builder --chown=node:node /app /app + +VOLUME /app/uploads + +WORKDIR /app/bundle + +EXPOSE 3000 + +CMD ["node", "--force-fips", "main.js"] diff --git a/apps/meteor/app/2fa/server/code/index.ts b/apps/meteor/app/2fa/server/code/index.ts index 8242b1c66185e..a9934e6fe7e27 100644 --- a/apps/meteor/app/2fa/server/code/index.ts +++ b/apps/meteor/app/2fa/server/code/index.ts @@ -60,7 +60,7 @@ function getFingerprintFromConnection(connection: IMethodConnection): string { clientAddress: connection.clientAddress, }); - return crypto.createHash('md5').update(data).digest('hex'); + return crypto.createHash('sha256').update(data).digest('hex'); } function getRememberDate(from: Date = new Date()): Date | undefined { diff --git a/apps/meteor/app/api/server/v1/misc.ts b/apps/meteor/app/api/server/v1/misc.ts index d7b0be701a3f5..8a7dbabc5c7e6 100644 --- a/apps/meteor/app/api/server/v1/misc.ts +++ b/apps/meteor/app/api/server/v1/misc.ts @@ -632,7 +632,7 @@ API.v1.post( const connectionId = this.token || crypto - .createHash('md5') + .createHash('sha256') .update((this.requestIp ?? '') + this.user._id) .digest('hex'); @@ -693,7 +693,7 @@ API.v1.post( const connectionId = this.token || crypto - .createHash('md5') + .createHash('sha256') .update(this.requestIp ?? '') .digest('hex'); diff --git a/apps/meteor/app/statistics/server/lib/statistics.ts b/apps/meteor/app/statistics/server/lib/statistics.ts index 8c810acaad70b..91bd03d468606 100644 --- a/apps/meteor/app/statistics/server/lib/statistics.ts +++ b/apps/meteor/app/statistics/server/lib/statistics.ts @@ -1,4 +1,5 @@ import { log } from 'console'; +import crypto from 'node:crypto'; import os from 'node:os'; import { Analytics, Team, VideoConf, Presence } from '@rocket.chat/core-services'; @@ -345,6 +346,8 @@ export const statistics = { platform: process.env.DEPLOY_PLATFORM || 'selfinstall', }; + statistics.fips = !!crypto.getFips(); + statistics.readReceiptsEnabled = settings.get('Message_Read_Receipt_Enabled'); statistics.readReceiptsDetailed = settings.get('Message_Read_Receipt_Store_Users'); diff --git a/apps/meteor/server/startup/serverRunning.ts b/apps/meteor/server/startup/serverRunning.ts index 9408895221357..799852e119bae 100644 --- a/apps/meteor/server/startup/serverRunning.ts +++ b/apps/meteor/server/startup/serverRunning.ts @@ -1,3 +1,4 @@ +import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; @@ -49,6 +50,8 @@ Meteor.startup(async () => { ` Platform: ${process.platform}`, ` Process Port: ${process.env.PORT}`, ` Site URL: ${settings.get('Site_Url')}`, + ` OpenSSL Version: ${process.versions.openssl}`, + ` FIPS Provider: ${crypto.getFips() ? 'Enabled' : 'Disabled'}`, ]; if (Info.commit?.hash) { diff --git a/docker-compose-ci.fips.yml b/docker-compose-ci.fips.yml new file mode 100644 index 0000000000000..22781fac4ed3b --- /dev/null +++ b/docker-compose-ci.fips.yml @@ -0,0 +1,37 @@ +services: + rocketchat: + build: + target: release-fips + image: ghcr.io/${LOWERCASE_REPOSITORY}/rocket.chat:${DOCKER_TAG}-fips + healthcheck: + test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:3000/livez', (res) => { process.exit(res.statusCode === 200 ? 0 : 1); }).on('error', () => process.exit(1));"] + + account-service: + build: + target: release-fips + image: ghcr.io/${LOWERCASE_REPOSITORY}/account-service:${DOCKER_TAG}-fips + + authorization-service: + build: + target: release-fips + image: ghcr.io/${LOWERCASE_REPOSITORY}/authorization-service:${DOCKER_TAG}-fips + + presence-service: + build: + target: release-fips + image: ghcr.io/${LOWERCASE_REPOSITORY}/presence-service:${DOCKER_TAG}-fips + + ddp-streamer-service: + build: + target: release-fips + image: ghcr.io/${LOWERCASE_REPOSITORY}/ddp-streamer-service:${DOCKER_TAG}-fips + + queue-worker-service: + build: + target: release-fips + image: ghcr.io/${LOWERCASE_REPOSITORY}/queue-worker-service:${DOCKER_TAG}-fips + + omnichannel-transcript-service: + build: + target: release-fips + image: ghcr.io/${LOWERCASE_REPOSITORY}/omnichannel-transcript-service:${DOCKER_TAG}-fips diff --git a/docker-compose-ci.yml b/docker-compose-ci.yml index 305f0c81e2cc8..963a6614e3229 100644 --- a/docker-compose-ci.yml +++ b/docker-compose-ci.yml @@ -5,6 +5,7 @@ services: build: dockerfile: ${GITHUB_WORKSPACE:-}/apps/meteor/.docker/Dockerfile.alpine context: /tmp/build + target: release-standard x-bake: platforms: - linux/amd64 @@ -48,6 +49,7 @@ services: build: dockerfile: ee/apps/authorization-service/Dockerfile context: . + target: release-standard x-bake: platforms: - linux/amd64 @@ -68,6 +70,7 @@ services: build: dockerfile: ee/apps/account-service/Dockerfile context: . + target: release-standard x-bake: platforms: - linux/amd64 @@ -88,6 +91,7 @@ services: build: dockerfile: ee/apps/presence-service/Dockerfile context: . + target: release-standard x-bake: platforms: - linux/amd64 @@ -108,6 +112,7 @@ services: build: dockerfile: ee/apps/ddp-streamer/Dockerfile context: . + target: release-standard x-bake: platforms: - linux/amd64 @@ -134,6 +139,7 @@ services: build: dockerfile: ee/apps/queue-worker/Dockerfile context: . + target: release-standard x-bake: platforms: - linux/amd64 @@ -154,6 +160,7 @@ services: build: dockerfile: ee/apps/omnichannel-transcript/Dockerfile context: . + target: release-standard x-bake: platforms: - linux/amd64 diff --git a/ee/apps/account-service/Dockerfile b/ee/apps/account-service/Dockerfile index f89df4181a69f..50c0f423ea984 100644 --- a/ee/apps/account-service/Dockerfile +++ b/ee/apps/account-service/Dockerfile @@ -87,7 +87,7 @@ WORKDIR /app/ee/apps/${SERVICE} RUN yarn workspaces focus --production -FROM node:22.22.3-alpine3.23 +FROM node:22.22.3-alpine3.23 AS release-standard ARG SERVICE @@ -113,3 +113,13 @@ USER rocketchat EXPOSE 3000 9458 CMD ["node", "src/service.js"] + +FROM rocketchat/dhi-node:22.22.3-alpine3.23-fips AS release-fips +ARG SERVICE +ENV NODE_ENV=production \ + PORT=3000 +COPY --chown=node:node --from=builder /app /app +WORKDIR /app/ee/apps/${SERVICE} +USER node +EXPOSE 3000 9458 +CMD ["node", "--force-fips", "--require", "./src/fips.js", "src/service.js"] diff --git a/ee/apps/account-service/src/fips.ts b/ee/apps/account-service/src/fips.ts new file mode 100644 index 0000000000000..fab82191a20c8 --- /dev/null +++ b/ee/apps/account-service/src/fips.ts @@ -0,0 +1,9 @@ +import crypto from 'crypto'; + +crypto.setFips(true); + +if (!crypto.getFips()) { + throw new Error('FIPS mode was not enabled after crypto.setFips(true)'); +} + +console.log('FIPS COMPLIANCE CHECK: YES'); diff --git a/ee/apps/authorization-service/Dockerfile b/ee/apps/authorization-service/Dockerfile index cf81d8593e2a0..b706524fd7d64 100644 --- a/ee/apps/authorization-service/Dockerfile +++ b/ee/apps/authorization-service/Dockerfile @@ -93,7 +93,7 @@ WORKDIR /app/ee/apps/${SERVICE} RUN yarn workspaces focus --production -FROM node:22.22.3-alpine3.23 +FROM node:22.22.3-alpine3.23 AS release-standard ARG SERVICE @@ -119,3 +119,13 @@ USER rocketchat EXPOSE 3000 9458 CMD ["node", "src/service.js"] + +FROM rocketchat/dhi-node:22.22.3-alpine3.23-fips AS release-fips +ARG SERVICE +ENV NODE_ENV=production \ + PORT=3000 +COPY --chown=node:node --from=builder /app /app +WORKDIR /app/ee/apps/${SERVICE} +USER node +EXPOSE 3000 9458 +CMD ["node", "--force-fips", "--require", "./src/fips.js", "src/service.js"] diff --git a/ee/apps/authorization-service/src/fips.ts b/ee/apps/authorization-service/src/fips.ts new file mode 100644 index 0000000000000..fab82191a20c8 --- /dev/null +++ b/ee/apps/authorization-service/src/fips.ts @@ -0,0 +1,9 @@ +import crypto from 'crypto'; + +crypto.setFips(true); + +if (!crypto.getFips()) { + throw new Error('FIPS mode was not enabled after crypto.setFips(true)'); +} + +console.log('FIPS COMPLIANCE CHECK: YES'); diff --git a/ee/apps/authorization-service/tsconfig.json b/ee/apps/authorization-service/tsconfig.json index 04f7fc0034d1c..7c04aabd6bb1b 100644 --- a/ee/apps/authorization-service/tsconfig.json +++ b/ee/apps/authorization-service/tsconfig.json @@ -4,7 +4,7 @@ "strictPropertyInitialization": false, // TODO: Remove this line "outDir": "./dist" }, - "files": ["./src/service.ts"], + "files": ["./src/service.ts", "./src/fips.ts"], "include": ["../../../apps/meteor/definition/externals/meteor"], "exclude": ["./dist"] } diff --git a/ee/apps/ddp-streamer/Dockerfile b/ee/apps/ddp-streamer/Dockerfile index a1d430af04c90..4aa4e5f9b1ac8 100644 --- a/ee/apps/ddp-streamer/Dockerfile +++ b/ee/apps/ddp-streamer/Dockerfile @@ -90,7 +90,7 @@ WORKDIR /app/ee/apps/${SERVICE} RUN yarn workspaces focus --production -FROM node:22.22.3-alpine3.23 +FROM node:22.22.3-alpine3.23 AS release-standard ARG SERVICE @@ -116,3 +116,13 @@ USER rocketchat EXPOSE 3000 9458 CMD ["node", "src/service.js"] + +FROM rocketchat/dhi-node:22.22.3-alpine3.23-fips AS release-fips +ARG SERVICE +ENV NODE_ENV=production \ + PORT=3000 +COPY --chown=node:node --from=builder /app /app +WORKDIR /app/ee/apps/${SERVICE} +USER node +EXPOSE 3000 9458 +CMD ["node", "--force-fips", "--require", "./src/fips.js", "src/service.js"] diff --git a/ee/apps/ddp-streamer/src/fips.ts b/ee/apps/ddp-streamer/src/fips.ts new file mode 100644 index 0000000000000..37651fc8bf1a6 --- /dev/null +++ b/ee/apps/ddp-streamer/src/fips.ts @@ -0,0 +1,213 @@ +/** + * ============================================================================== + * SECURITY AUDIT EXEMPTION / FIPS 140-3 WORKAROUND + * ============================================================================== + * Context: + * Node.js running in FIPS 140-3 mode strictly disables native SHA-1 execution. + * However, RFC 6455 (WebSockets) strictly requires SHA-1 to generate the + * Sec-WebSocket-Accept handshake header. + * * Justification: + * The WebSocket protocol uses SHA-1 purely for framing/handshake validation, + * NOT for cryptographic security. To allow the 'ws' library to function without + * crashing the Node process, we intercept SHA-1 calls specifically for the + * 60-byte WebSocket handshake and process them using a highly-optimized, + * zero-allocation, pure-JS implementation. + * ============================================================================== + */ +import crypto from 'crypto'; + +crypto.setFips(true); + +if (!crypto.getFips()) { + throw new Error('FIPS mode was not enabled after crypto.setFips(true)'); +} + +console.log('FIPS COMPLIANCE CHECK: YES'); + +const blocks = new Uint32Array(32); +const w = new Uint32Array(80); +const hashBuffer = Buffer.alloc(20); +const WEBSOCKET_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; +const SEC_WEBSOCKET_KEY_BASE64_PATTERN = /^[A-Za-z0-9+/]{22}==$/; + +const generateWebSocketAccept = (message: string): string => { + if (message.length !== 60) { + throw new Error(`Expected 60-byte input for WS Accept, got ${message.length}`); + } + + blocks.fill(0); + + let h0 = 0x67452301; + let h1 = 0xefcdab89; + let h2 = 0x98badcfe; + let h3 = 0x10325476; + let h4 = 0xc3d2e1f0; + + for (let i = 0; i < 60; i++) blocks[i >> 2] |= message.charCodeAt(i) << (24 - (i % 4) * 8); + blocks[15] = 0x80000000; + blocks[31] = 480; + + const rotl = (n: number, b: number) => (n << b) | (n >>> (32 - b)); + + for (let chunk = 0; chunk < 2; chunk++) { + const offset = chunk * 16; + for (let i = 0; i < 16; i++) w[i] = blocks[offset + i]; + for (let i = 16; i < 80; i++) w[i] = rotl(w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16], 1); + + let a = h0; + let b = h1; + let c = h2; + let d = h3; + let e = h4; + let temp; + + for (let i = 0; i < 20; i++) { + temp = (rotl(a, 5) + (d ^ (b & (c ^ d))) + e + 0x5a827999 + w[i]) >>> 0; + e = d; + d = c; + c = rotl(b, 30); + b = a; + a = temp; + } + for (let i = 20; i < 40; i++) { + temp = (rotl(a, 5) + (b ^ c ^ d) + e + 0x6ed9eba1 + w[i]) >>> 0; + e = d; + d = c; + c = rotl(b, 30); + b = a; + a = temp; + } + for (let i = 40; i < 60; i++) { + temp = (rotl(a, 5) + ((b & c) | (b & d) | (c & d)) + e + 0x8f1bbcdc + w[i]) >>> 0; + e = d; + d = c; + c = rotl(b, 30); + b = a; + a = temp; + } + for (let i = 60; i < 80; i++) { + temp = (rotl(a, 5) + (b ^ c ^ d) + e + 0xca62c1d6 + w[i]) >>> 0; + e = d; + d = c; + c = rotl(b, 30); + b = a; + a = temp; + } + + h0 = (h0 + a) >>> 0; + h1 = (h1 + b) >>> 0; + h2 = (h2 + c) >>> 0; + h3 = (h3 + d) >>> 0; + h4 = (h4 + e) >>> 0; + } + + hashBuffer.writeUInt32BE(h0, 0); + hashBuffer.writeUInt32BE(h1, 4); + hashBuffer.writeUInt32BE(h2, 8); + hashBuffer.writeUInt32BE(h3, 12); + hashBuffer.writeUInt32BE(h4, 16); + + return hashBuffer.toString('base64'); +}; + +const originalCreateHash = crypto.createHash; + +const createUnsupportedSha1MethodError = (method: string): Error => + new Error( + `Unsupported SHA-1 hash API in FIPS mode: crypto.createHash('sha1').${method}. ` + + `Only update() and digest('base64') for the WebSocket handshake are supported.`, + ); + +const createUnsupportedSha1Method = (method: string) => { + return () => { + throw createUnsupportedSha1MethodError(method); + }; +}; + +type Sha1UpdateCall = { type: 'string'; chunk: string; encoding?: BufferEncoding } | { type: 'buffer'; chunk: Buffer }; + +const isWebSocketHandshakeSha1Input = (input: string): boolean => { + if (input.length !== 60) { + return false; + } + + const secWebSocketKey = input.slice(0, 24); + const guid = input.slice(24); + + return guid === WEBSOCKET_GUID && SEC_WEBSOCKET_KEY_BASE64_PATTERN.test(secWebSocketKey); +}; + +crypto.createHash = function (algorithm: string, options?: crypto.HashOptions) { + if (algorithm.toLowerCase() === 'sha1') { + const updates: Sha1UpdateCall[] = []; + let wsProbeInput = ''; + let finalized = false; + + const mockHash = { + update(data: string | Buffer | NodeJS.ArrayBufferView, inputEncoding?: crypto.Encoding) { + if (finalized) { + throw new Error('Digest already called'); + } + + if (typeof data === 'string') { + const encoding = inputEncoding as BufferEncoding | undefined; + updates.push({ type: 'string', chunk: data, encoding }); + wsProbeInput += Buffer.from(data, encoding ?? 'utf8').toString('latin1'); + } else if (Buffer.isBuffer(data)) { + const chunk = Buffer.from(data); + updates.push({ type: 'buffer', chunk }); + wsProbeInput += chunk.toString('latin1'); + } else { + const chunk = Buffer.from(data.buffer, data.byteOffset, data.byteLength); + updates.push({ type: 'buffer', chunk: Buffer.from(chunk) }); + wsProbeInput += chunk.toString('latin1'); + } + return this; + }, + digest(encoding?: crypto.BinaryToTextEncoding) { + if (finalized) { + throw new Error('Digest already called'); + } + + finalized = true; + + if (encoding === 'base64' && isWebSocketHandshakeSha1Input(wsProbeInput)) { + return generateWebSocketAccept(wsProbeInput); + } + // If it's not the exact WS handshake, pass it back to native (which will throw FIPS error) + const hash = originalCreateHash(algorithm, options); + + for (const updateCall of updates) { + if (updateCall.type === 'string') { + if (updateCall.encoding) { + hash.update(updateCall.chunk, updateCall.encoding); + } else { + hash.update(updateCall.chunk); + } + } else { + hash.update(updateCall.chunk); + } + } + + return encoding ? hash.digest(encoding) : hash.digest(); + }, + copy: createUnsupportedSha1Method('copy'), + on: createUnsupportedSha1Method('on'), + once: createUnsupportedSha1Method('once'), + emit: createUnsupportedSha1Method('emit'), + pipe: createUnsupportedSha1Method('pipe'), + } as Record; + + const guardedHash = new Proxy(mockHash, { + get(target, prop, receiver) { + if (typeof prop === 'string' && !(prop in target)) { + throw createUnsupportedSha1MethodError(prop); + } + return Reflect.get(target, prop, receiver); + }, + }); + + return guardedHash as unknown as crypto.Hash; + } + return originalCreateHash(algorithm, options); +}; diff --git a/ee/apps/omnichannel-transcript/Dockerfile b/ee/apps/omnichannel-transcript/Dockerfile index 9b8e8b19a8abc..8734cc6d1b49a 100644 --- a/ee/apps/omnichannel-transcript/Dockerfile +++ b/ee/apps/omnichannel-transcript/Dockerfile @@ -100,7 +100,7 @@ WORKDIR /app/ee/apps/${SERVICE} RUN yarn workspaces focus --production -FROM node:22.22.3-alpine3.23 +FROM node:22.22.3-alpine3.23 AS release-standard ARG SERVICE @@ -126,3 +126,13 @@ USER rocketchat EXPOSE 3000 9458 CMD ["node", "src/service.js"] + +FROM rocketchat/dhi-node:22.22.3-alpine3.23-fips AS release-fips +ARG SERVICE +ENV NODE_ENV=production \ + PORT=3000 +COPY --chown=node:node --from=builder /app /app +WORKDIR /app/ee/apps/${SERVICE} +USER node +EXPOSE 3000 9458 +CMD ["node", "--force-fips", "--require", "./src/fips.js", "src/service.js"] diff --git a/ee/apps/omnichannel-transcript/src/fips.ts b/ee/apps/omnichannel-transcript/src/fips.ts new file mode 100644 index 0000000000000..fab82191a20c8 --- /dev/null +++ b/ee/apps/omnichannel-transcript/src/fips.ts @@ -0,0 +1,9 @@ +import crypto from 'crypto'; + +crypto.setFips(true); + +if (!crypto.getFips()) { + throw new Error('FIPS mode was not enabled after crypto.setFips(true)'); +} + +console.log('FIPS COMPLIANCE CHECK: YES'); diff --git a/ee/apps/omnichannel-transcript/tsconfig.json b/ee/apps/omnichannel-transcript/tsconfig.json index 6c7f2d916a732..253de00013a22 100644 --- a/ee/apps/omnichannel-transcript/tsconfig.json +++ b/ee/apps/omnichannel-transcript/tsconfig.json @@ -4,7 +4,7 @@ "strictPropertyInitialization": false, "outDir": "./dist/ee/apps/omnichannel-transcript/src", }, - "files": ["./src/service.ts"], + "files": ["./src/service.ts", "./src/fips.ts"], "include": ["../../../apps/meteor/definition/externals/meteor"], "exclude": ["./dist"] } diff --git a/ee/apps/presence-service/Dockerfile b/ee/apps/presence-service/Dockerfile index e4d201aa24f64..a0c1caf519058 100644 --- a/ee/apps/presence-service/Dockerfile +++ b/ee/apps/presence-service/Dockerfile @@ -88,7 +88,7 @@ WORKDIR /app/ee/apps/${SERVICE} RUN yarn workspaces focus --production -FROM node:22.22.3-alpine3.23 +FROM node:22.22.3-alpine3.23 AS release-standard ARG SERVICE @@ -114,3 +114,13 @@ USER rocketchat EXPOSE 3000 9458 CMD ["node", "src/service.js"] + +FROM rocketchat/dhi-node:22.22.3-alpine3.23-fips AS release-fips +ARG SERVICE +ENV NODE_ENV=production \ + PORT=3000 +COPY --chown=node:node --from=builder /app /app +WORKDIR /app/ee/apps/${SERVICE} +USER node +EXPOSE 3000 9458 +CMD ["node", "--force-fips", "--require", "./src/fips.js", "src/service.js"] diff --git a/ee/apps/presence-service/src/fips.ts b/ee/apps/presence-service/src/fips.ts new file mode 100644 index 0000000000000..fab82191a20c8 --- /dev/null +++ b/ee/apps/presence-service/src/fips.ts @@ -0,0 +1,9 @@ +import crypto from 'crypto'; + +crypto.setFips(true); + +if (!crypto.getFips()) { + throw new Error('FIPS mode was not enabled after crypto.setFips(true)'); +} + +console.log('FIPS COMPLIANCE CHECK: YES'); diff --git a/ee/apps/presence-service/tsconfig.json b/ee/apps/presence-service/tsconfig.json index ff29a55af231c..34dfe4e779780 100644 --- a/ee/apps/presence-service/tsconfig.json +++ b/ee/apps/presence-service/tsconfig.json @@ -4,7 +4,7 @@ "strictPropertyInitialization": false, // TODO: Remove this line "outDir": "./dist/ee/apps/presence-service/src", }, - "files": ["./src/service.ts"], + "files": ["./src/service.ts", "./src/fips.ts"], "include": ["../../../apps/meteor/definition/externals/meteor"], "exclude": ["./dist"] } diff --git a/ee/apps/queue-worker/Dockerfile b/ee/apps/queue-worker/Dockerfile index 9b8e8b19a8abc..8734cc6d1b49a 100644 --- a/ee/apps/queue-worker/Dockerfile +++ b/ee/apps/queue-worker/Dockerfile @@ -100,7 +100,7 @@ WORKDIR /app/ee/apps/${SERVICE} RUN yarn workspaces focus --production -FROM node:22.22.3-alpine3.23 +FROM node:22.22.3-alpine3.23 AS release-standard ARG SERVICE @@ -126,3 +126,13 @@ USER rocketchat EXPOSE 3000 9458 CMD ["node", "src/service.js"] + +FROM rocketchat/dhi-node:22.22.3-alpine3.23-fips AS release-fips +ARG SERVICE +ENV NODE_ENV=production \ + PORT=3000 +COPY --chown=node:node --from=builder /app /app +WORKDIR /app/ee/apps/${SERVICE} +USER node +EXPOSE 3000 9458 +CMD ["node", "--force-fips", "--require", "./src/fips.js", "src/service.js"] diff --git a/ee/apps/queue-worker/src/fips.ts b/ee/apps/queue-worker/src/fips.ts new file mode 100644 index 0000000000000..fab82191a20c8 --- /dev/null +++ b/ee/apps/queue-worker/src/fips.ts @@ -0,0 +1,9 @@ +import crypto from 'crypto'; + +crypto.setFips(true); + +if (!crypto.getFips()) { + throw new Error('FIPS mode was not enabled after crypto.setFips(true)'); +} + +console.log('FIPS COMPLIANCE CHECK: YES'); diff --git a/ee/apps/queue-worker/tsconfig.json b/ee/apps/queue-worker/tsconfig.json index c12ebd48bcdc0..c36303f2794d1 100644 --- a/ee/apps/queue-worker/tsconfig.json +++ b/ee/apps/queue-worker/tsconfig.json @@ -4,7 +4,7 @@ "strictPropertyInitialization": false, "outDir": "./dist/ee/apps/queue-worker/src", }, - "files": ["./src/service.ts"], + "files": ["./src/service.ts", "./src/fips.ts"], "include": ["../../../apps/meteor/definition/externals/meteor"], "exclude": ["./dist"] } diff --git a/ee/packages/federation-matrix/docker-compose.test.yml b/ee/packages/federation-matrix/docker-compose.test.yml index ad06faeb0ef38..4c9fc6bf44390 100644 --- a/ee/packages/federation-matrix/docker-compose.test.yml +++ b/ee/packages/federation-matrix/docker-compose.test.yml @@ -82,6 +82,7 @@ services: build: context: ${ROCKETCHAT_BUILD_CONTEXT:-./test/dist} dockerfile: ${ROCKETCHAT_DOCKERFILE:-../../../apps/meteor/.docker/Dockerfile.alpine} + target: release-standard image: ${ROCKETCHAT_IMAGE:-rocketchat/rocket.chat:latest} profiles: - test diff --git a/ee/packages/federation-matrix/src/api/.well-known/server.ts b/ee/packages/federation-matrix/src/api/.well-known/server.ts index 9bb991ab0d7e9..262afa5d3249e 100644 --- a/ee/packages/federation-matrix/src/api/.well-known/server.ts +++ b/ee/packages/federation-matrix/src/api/.well-known/server.ts @@ -32,7 +32,7 @@ export const getWellKnownRoutes = () => { async (c) => { const responseData = federationSDK.getWellKnownHostData(); - const etag = createHash('md5').update(JSON.stringify(responseData)).digest('hex'); + const etag = createHash('sha256').update(JSON.stringify(responseData)).digest('hex'); c.header('ETag', etag); c.header('Content-Type', 'application/json'); diff --git a/packages/core-typings/src/IStats.ts b/packages/core-typings/src/IStats.ts index 37280c86b5cf0..939b3558c8d72 100644 --- a/packages/core-typings/src/IStats.ts +++ b/packages/core-typings/src/IStats.ts @@ -273,4 +273,5 @@ export interface IStats extends IRocketChatRecord { abacTotalAttributeValues?: number; abacRoomsEnrolled?: number; allowUnsafeQueryAndFieldsApiParamsEnabled?: boolean; + fips?: boolean; }