From e94d4c3986e5fda93196d5efd0095e44c1003c20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Tue, 16 Jun 2026 15:16:49 +0700 Subject: [PATCH] feat(crypto): FIPS 140-3 compliant build and runtime checks (#12233) # Which Problems Are Solved Enable FIPS 140-3 compliant build. # How the Problems Are Solved - Add runtime config validation, if the FIPS flag is enabled fail the application when a non-compliant hasher is used, or throw a warning when a legacy verifier is used - Add a build matrix for FIPS certified build: - Go binary is built with `GOFIPS140=certified` - Login container uses a separate base: [ubi9](https://catalog.redhat.com/en/software/containers/ubi9/ubi/615bcf606feffc5384e8452e) from redhat which provides a FIPS certified OpenSSL (used by NodeJS TLS stack) - Non-FIPS images where already pushed to both Github Container Registry and Google Artifact Repository (GAR). Fips images are only pushed to the GAR. - Tag versions are suffixed `-fips`. So on release the following images will be additionally available: ``` europe-docker.pkg.dev/zitadel-common/zitadel-repo/zitadel-login:vX.Y.Z-fips europe-docker.pkg.dev/zitadel-common/zitadel-repo/zitadel:vX.Y.Z-fips-debug europe-docker.pkg.dev/zitadel-common/zitadel-repo/zitadel:vX.Y.Z-fips ``` # Other changes - Bumb Go toolchain. At least v1.25.10 is required for a GOFIPS140=certified setting. # Additional Context - Closes https://github.com/zitadel/zitadel/issues/4335 - Build [test run](https://github.com/zitadel/zitadel/actions/runs/27253916052) pushing FIPS and non-FIPS images --- .github/workflows/lint_test_build.yml | 1 - .github/workflows/pack.yml | 139 +++++++++++++++++--- .github/workflows/release.yml | 18 +++ apps/api/project.json | 7 +- apps/login/Dockerfile.fips | 23 ++++ apps/login/project.json | 13 ++ apps/login/scripts/healthcheck.mjs | 6 + cmd/default_config_test.go | 75 +++++++++++ cmd/defaults.yaml | 2 + cmd/defaults_fips.yaml | 14 ++ cmd/zitadel.go | 28 +++- go.mod | 2 +- internal/crypto/fips140.go | 99 ++++++++++++++ internal/crypto/fips140_test.go | 181 ++++++++++++++++++++++++++ internal/crypto/passwap.go | 3 + 15 files changed, 588 insertions(+), 23 deletions(-) create mode 100644 apps/login/Dockerfile.fips create mode 100644 cmd/default_config_test.go create mode 100644 cmd/defaults_fips.yaml create mode 100644 internal/crypto/fips140.go create mode 100644 internal/crypto/fips140_test.go diff --git a/.github/workflows/lint_test_build.yml b/.github/workflows/lint_test_build.yml index fb85a2250b..d7208c07ad 100644 --- a/.github/workflows/lint_test_build.yml +++ b/.github/workflows/lint_test_build.yml @@ -58,7 +58,6 @@ jobs: - name: Lint, Test and Build env: NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN || secrets.NX_CLOUD_ACCESS_TOKEN_READONLY }} - # NX_NATIVE_LOGGING: "nx::native::cache,nx::native::db" NX_DAEMON: "false" NX_DISABLE_DB: "true" run: pnpm nx affected --nxBail --targets lint test build --exclude @zitadel/docs diff --git a/.github/workflows/pack.yml b/.github/workflows/pack.yml index 8a92edbd6c..649d4dc321 100644 --- a/.github/workflows/pack.yml +++ b/.github/workflows/pack.yml @@ -40,7 +40,7 @@ jobs: semantic_version: ${{ inputs.semantic_version }} dry_run: true - pack: + pack-archives: runs-on: group: zitadel-public environment: ${{ github.ref_protected == 'true' && 'Protected' || null }} @@ -67,10 +67,6 @@ jobs: with: node-version: ${{ inputs.node_version }} cache: "pnpm" - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - with: - version: v0.28.0 - name: Install dependencies run: pnpm install --frozen-lockfile - name: Pack API and Login @@ -88,11 +84,59 @@ jobs: with: name: zitadel-archives path: .artifacts/pack + + docker: + runs-on: + group: zitadel-public + environment: ${{ github.ref_protected == 'true' && 'Protected' || null }} + needs: [version, pack-archives] + permissions: + contents: read + packages: write + strategy: + matrix: + include: + - gofips140: "off" + tag_suffix: "" + push_ghcr: true + - gofips140: "certified" + tag_suffix: "-fips" + push_ghcr: false + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + filter: tree:0 + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + run_install: false + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ inputs.node_version }} + cache: "pnpm" + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Build Linux API binaries + env: + ZITADEL_GOFIPS140: ${{ matrix.gofips140 }} + ZITADEL_VERSION: ${{ needs.version.outputs.version }} + NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN || secrets.NX_CLOUD_ACCESS_TOKEN_READONLY }} + run: pnpm nx run-many --nxBail -p @zitadel/api -t pack-linux-amd64,pack-linux-arm64 - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + with: + version: v0.28.0 - name: Login to Docker registry + if: matrix.push_ghcr uses: docker/login-action@v3 with: registry: ghcr.io @@ -104,24 +148,36 @@ jobs: registry: europe-docker.pkg.dev username: _json_key_base64 password: ${{ secrets.GCR_JSON_KEY_BASE64 }} + - name: Set API image names + id: api-images + run: | + if [ "${{ matrix.push_ghcr }}" = "true" ]; then + { + echo "${{ inputs.image_name_github_api }}" + echo "${{ inputs.image_name_google_api }}" + } > api-images.txt + else + echo "${{ inputs.image_name_google_api }}" > api-images.txt + fi + echo "list<> "$GITHUB_OUTPUT" + cat api-images.txt >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" - name: Generate Standard Tags and Labels from the GitHub Context for the API Scratch Container Image id: scratch-meta uses: docker/metadata-action@v5 with: - images: | - ${{ inputs.image_name_github_api }} - ${{ inputs.image_name_google_api }} + images: ${{ steps.api-images.outputs.list }} labels: ${{ env.default_labels}} tags: | - type=sha,prefix=,suffix=,format=long + type=sha,prefix=,suffix=${{ matrix.tag_suffix }},format=long - name: Build and Push the SHA-tagged API Scratch Container Image id: build-scratch uses: docker/build-push-action@v6 timeout-minutes: 3 with: context: . - cache-from: type=gha - cache-to: type=gha,mode=max + cache-from: type=gha,scope=api-scratch-${{ matrix.gofips140 }} + cache-to: type=gha,mode=max,scope=api-scratch-${{ matrix.gofips140 }} file: apps/api/Dockerfile target: final platforms: linux/amd64,linux/arm64 @@ -132,20 +188,19 @@ jobs: id: debug-meta uses: docker/metadata-action@v5 with: - images: | - ${{ inputs.image_name_github_api }} - ${{ inputs.image_name_google_api }} + images: ${{ steps.api-images.outputs.list }} labels: ${{ env.default_labels}} tags: | - type=sha,prefix=,suffix=-debug,format=long + type=sha,prefix=,suffix=${{ matrix.tag_suffix }}-debug,format=long - name: Build and Push the SHA-tagged API Debug Container Image + if: matrix.push_ghcr id: build-debug uses: docker/build-push-action@v6 timeout-minutes: 5 with: context: . - cache-from: type=gha - cache-to: type=gha,mode=max + cache-from: type=gha,scope=api-debug-${{ matrix.gofips140 }} + cache-to: type=gha,mode=max,scope=api-debug-${{ matrix.gofips140 }} file: apps/api/Dockerfile target: builder platforms: linux/amd64,linux/arm64 @@ -153,7 +208,25 @@ jobs: labels: ${{ steps.debug-meta.outputs.labels }} tags: ${{ steps.debug-meta.outputs.tags }} outputs: type=image,name=${{ inputs.image_name_github_api }},name-canonical=true + - name: Build and Push the SHA-tagged API Debug Container Image (GAR only) + if: ${{ !matrix.push_ghcr }} + uses: docker/build-push-action@v6 + timeout-minutes: 5 + with: + context: . + cache-from: type=gha,scope=api-debug-${{ matrix.gofips140 }} + cache-to: type=gha,mode=max,scope=api-debug-${{ matrix.gofips140 }} + file: apps/api/Dockerfile + target: builder + platforms: linux/amd64,linux/arm64 + push: true + labels: ${{ steps.debug-meta.outputs.labels }} + tags: ${{ steps.debug-meta.outputs.tags }} + - name: Build Login standalone + if: matrix.push_ghcr + run: pnpm nx run --nxBail @zitadel/login:build - name: Generate Standard Tags and Labels from the GitHub Context for the Login Container Image + if: matrix.push_ghcr id: login-meta uses: docker/metadata-action@v5 with: @@ -166,14 +239,42 @@ jobs: tags: | type=sha,prefix=,suffix=,format=long - name: Build and Push the SHA-tagged Login Container Image + if: matrix.push_ghcr id: build-login uses: docker/build-push-action@v6 timeout-minutes: 3 with: context: apps/login - cache-from: type=gha - cache-to: type=gha,mode=max + cache-from: type=gha,scope=login + cache-to: type=gha,mode=max,scope=login platforms: linux/amd64,linux/arm64 push: true labels: ${{ steps.login-meta.outputs.labels }} tags: ${{ steps.login-meta.outputs.tags }} + - name: Build Login standalone + if: ${{ !matrix.push_ghcr }} + run: pnpm nx run --nxBail @zitadel/login:build + - name: Generate tags for FIPS Login image + if: ${{ !matrix.push_ghcr }} + id: login-fips-meta + uses: docker/metadata-action@v5 + with: + images: ${{ inputs.image_name_google_login }} + labels: | + org.opencontainers.image.licenses=MIT + ${{ env.default_labels}} + tags: | + type=sha,prefix=,suffix=-fips,format=long + - name: Build and Push FIPS Login image (GAR only) + if: ${{ !matrix.push_ghcr }} + uses: docker/build-push-action@v6 + timeout-minutes: 3 + with: + context: apps/login + file: apps/login/Dockerfile.fips + cache-from: type=gha,scope=login-fips + cache-to: type=gha,mode=max,scope=login-fips + platforms: linux/amd64,linux/arm64 + push: true + labels: ${{ steps.login-fips-meta.outputs.labels }} + tags: ${{ steps.login-fips-meta.outputs.tags }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b68fb62a0a..051e85a510 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -103,12 +103,21 @@ jobs: docker buildx imagetools create \ --tag ${{ inputs.image_name_google_api }}:${{ needs.version.outputs.version }} \ ${{ inputs.image_name_google_api }}:${{ github.sha }} + docker buildx imagetools create \ + --tag ${{ inputs.image_name_google_api }}:${{ needs.version.outputs.version }}-fips \ + ${{ inputs.image_name_google_api }}:${{ github.sha }}-fips + docker buildx imagetools create \ + --tag ${{ inputs.image_name_google_api }}:${{ needs.version.outputs.version }}-fips-debug \ + ${{ inputs.image_name_google_api }}:${{ github.sha }}-fips-debug docker buildx imagetools create \ --tag ${{ inputs.image_name_github_login }}:${{ needs.version.outputs.version }} \ ${{ inputs.image_name_github_login }}:${{ github.sha }} docker buildx imagetools create \ --tag ${{ inputs.image_name_google_login }}:${{ needs.version.outputs.version }} \ ${{ inputs.image_name_google_login }}:${{ github.sha }} + docker buildx imagetools create \ + --tag ${{ inputs.image_name_google_login }}:${{ needs.version.outputs.version }}-fips \ + ${{ inputs.image_name_google_login }}:${{ github.sha }}-fips - name: Publish latest if: ${{ github.ref_name == 'next' }} @@ -122,6 +131,15 @@ jobs: docker buildx imagetools create \ --tag ${{ inputs.image_name_github_login }}:latest \ ${{ inputs.image_name_github_login }}:${{ github.sha }} + docker buildx imagetools create \ + --tag ${{ inputs.image_name_google_api }}:latest-fips \ + ${{ inputs.image_name_google_api }}:${{ github.sha }}-fips + docker buildx imagetools create \ + --tag ${{ inputs.image_name_google_api }}:latest-fips-debug \ + ${{ inputs.image_name_google_api }}:${{ github.sha }}-fips-debug + docker buildx imagetools create \ + --tag ${{ inputs.image_name_google_login }}:latest-fips \ + ${{ inputs.image_name_google_login }}:${{ github.sha }}-fips homebrew-tap: runs-on: ubuntu-22.04 diff --git a/apps/api/project.json b/apps/api/project.json index 262699f2f4..b755f69824 100644 --- a/apps/api/project.json +++ b/apps/api/project.json @@ -4,6 +4,8 @@ "projectType": "application", "namedInputs": { "sources": [ + "{workspaceRoot}/cmd/defaults.yaml", + "{workspaceRoot}/cmd/defaults_fips.yaml", "{workspaceRoot}/cmd/**/*.go", "{workspaceRoot}/internal/**/*.go", "{workspaceRoot}/proto/**/*.go", @@ -23,6 +25,9 @@ { "env": "VERSION" }, + { + "env": "ZITADEL_GOFIPS140" + }, "runtime" ] }, @@ -416,7 +421,7 @@ "parallel": false, "commands": [ "mkdir -p .artifacts/pack .artifacts/bin/$GOOS/$GOARCH", - "bash -c 'EXT=\"\"; if [ \"$GOOS\" = \"windows\" ]; then EXT=\".exe\"; fi; echo \"Building for $GOOS-$GOARCH...\"; CGO_ENABLED=0 go build -o .artifacts/bin/$GOOS/$GOARCH/zitadel$EXT -ldflags=\"-s -w -X github.com/zitadel/zitadel/cmd/build.commit=$(git rev-parse --short HEAD) -X github.com/zitadel/zitadel/cmd/build.date=$(date \"+%Y-%m-%dT%T%z\" | sed -E \"s/.([0-9]{2})([0-9]{2})$/-\\1:\\2/\") -X github.com/zitadel/zitadel/cmd/build.version=${ZITADEL_VERSION}\"'", + "bash -c 'EXT=\"\"; if [ \"$GOOS\" = \"windows\" ]; then EXT=\".exe\"; fi; echo \"Building for $GOOS-$GOARCH (GOFIPS140=${ZITADEL_GOFIPS140:-off})...\"; CGO_ENABLED=0 GOFIPS140=${ZITADEL_GOFIPS140:-off} go build -o .artifacts/bin/$GOOS/$GOARCH/zitadel$EXT -ldflags=\"-s -w -X github.com/zitadel/zitadel/cmd/build.commit=$(git rev-parse --short HEAD) -X github.com/zitadel/zitadel/cmd/build.date=$(date \"+%Y-%m-%dT%T%z\" | sed -E \"s/.([0-9]{2})([0-9]{2})$/-\\1:\\2/\") -X github.com/zitadel/zitadel/cmd/build.version=${ZITADEL_VERSION}\"'", "bash -c 'EXT=\"\"; if [ \"$GOOS\" = \"windows\" ]; then EXT=\".exe\"; fi; FOLDER=\"zitadel-$GOOS-$GOARCH\"; mkdir -p \".artifacts/pack/$FOLDER\"; cp README.md LICENSE \".artifacts/bin/$GOOS/$GOARCH/zitadel$EXT\" \".artifacts/pack/$FOLDER/\"; tar -czvf \".artifacts/pack/$FOLDER.tar.gz\" -C .artifacts/pack \"$FOLDER\"; rm -rf \".artifacts/pack/$FOLDER\"'" ] } diff --git a/apps/login/Dockerfile.fips b/apps/login/Dockerfile.fips new file mode 100644 index 0000000000..478486149e --- /dev/null +++ b/apps/login/Dockerfile.fips @@ -0,0 +1,23 @@ +FROM registry.access.redhat.com/ubi9/nodejs-24 +USER root +RUN update-crypto-policies --set FIPS +WORKDIR /app +# If /.env-file/.env is mounted into the container, its variables are made available to the server before it starts up. +RUN mkdir -p /.env-file && touch /.env-file/.env && chown -R 1001:0 /.env-file + +COPY --chown=1001:0 .next/standalone ./ + +USER 1001 +ENV HOSTNAME="::" \ + PORT="3000" \ + NODE_ENV="production" \ + NODE_OPTIONS="--use-openssl-ca --openssl-shared-config --enable-fips --require /app/load-ssl-cert-dir.cjs" \ + SSL_CERT_FILE="/etc/pki/tls/certs/ca-bundle.crt" \ + ZITADEL_TLS_ENABLED="false" \ + ZITADEL_FIPS_REQUIRED="true" \ + OTEL_SERVICE_NAME="zitadel-login" \ + OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf" + +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD ["node", "/app/healthcheck.mjs", "/ui/v2/login/ready"] +ENTRYPOINT ["/app/entrypoint.sh", "node", "apps/login/server.js"] diff --git a/apps/login/project.json b/apps/login/project.json index 1aea46c78e..648d7b0c5f 100644 --- a/apps/login/project.json +++ b/apps/login/project.json @@ -42,6 +42,19 @@ "!{projectRoot}/acceptance/**/*" ] }, + "pack-fips": { + "description": "Builds a local FIPS-hardened Docker image for the ZITADEL Login UI (zitadel/zitadel-login:local-fips). Requires Docker daemon.", + "dependsOn": ["build"], + "command": "docker build -f apps/login/Dockerfile.fips -t zitadel/zitadel-login:local-fips apps/login", + "cache": false, + "inputs": [ + "default", + "{workspaceRoot}/pnpm-lock.yaml", + "{projectRoot}/Dockerfile.fips", + "!{projectRoot}/.env.*", + "!{projectRoot}/acceptance/**/*" + ] + }, "build-vercel": { "description": "Builds the Next.js Login application for Vercel deployment", "cache": false, diff --git a/apps/login/scripts/healthcheck.mjs b/apps/login/scripts/healthcheck.mjs index 9aa74e4a42..e2ec461a45 100644 --- a/apps/login/scripts/healthcheck.mjs +++ b/apps/login/scripts/healthcheck.mjs @@ -1,6 +1,12 @@ +import { getFips } from "node:crypto"; import * as http from "node:http"; import * as https from "node:https"; +if (process.env.ZITADEL_FIPS_REQUIRED === "true" && getFips() !== 1) { + console.error("Healthcheck failed: FIPS mode required but not enabled"); + process.exit(1); +} + const scheme = process.env.ZITADEL_TLS_ENABLED === "true" ? "https" : "http"; const port = process.env.PORT || "3000"; const url = new URL(process.argv[2] || `/ui/v2/login/healthy`, `${scheme}://localhost:${port}`); diff --git a/cmd/default_config_test.go b/cmd/default_config_test.go new file mode 100644 index 0000000000..6a51652015 --- /dev/null +++ b/cmd/default_config_test.go @@ -0,0 +1,75 @@ +package cmd + +import ( + "bytes" + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/crypto" +) + +func testViper(t *testing.T) *viper.Viper { + t.Helper() + v := viper.New() + v.SetConfigType("yaml") + return v +} + +func loadAndUnmarshalHashConfig(t *testing.T, v *viper.Viper, key string) crypto.HashConfig { + t.Helper() + var cfg crypto.HashConfig + require.NoError(t, v.UnmarshalKey(key, &cfg)) + return cfg +} + +func loadBaseDefaultConfig(t *testing.T, v *viper.Viper) { + t.Helper() + require.NoError(t, v.ReadConfig(bytes.NewBuffer(defaultConfig))) +} + +func TestLoadDefaultConfig_NonFIPSPasswordHasher(t *testing.T) { + v := testViper(t) + require.NoError(t, loadDefaultConfigInto(v)) + cfg := loadAndUnmarshalHashConfig(t, v, "SystemDefaults.PasswordHasher") + assert.Equal(t, crypto.HashNameBcrypt, cfg.Hasher.Algorithm) +} + +func TestLoadDefaultConfig_NonFIPSSecretHasher(t *testing.T) { + v := testViper(t) + require.NoError(t, loadDefaultConfigInto(v)) + cfg := loadAndUnmarshalHashConfig(t, v, "SystemDefaults.SecretHasher") + assert.Equal(t, crypto.HashNameBcrypt, cfg.Hasher.Algorithm) +} + +func TestLoadDefaultConfig_FIPSPasswordHasher(t *testing.T) { + v := testViper(t) + loadBaseDefaultConfig(t, v) + require.NoError(t, applyFipsDefaultOverlay(v)) + cfg := loadAndUnmarshalHashConfig(t, v, "SystemDefaults.PasswordHasher") + assert.Equal(t, crypto.HashNamePBKDF2, cfg.Hasher.Algorithm) + assert.Equal(t, 290000, v.GetInt("SystemDefaults.PasswordHasher.Hasher.Rounds")) + assert.Equal(t, "sha256", v.GetString("SystemDefaults.PasswordHasher.Hasher.Hash")) + assert.Empty(t, cfg.Verifiers) +} + +func TestLoadDefaultConfig_FIPSSecretHasher(t *testing.T) { + v := testViper(t) + loadBaseDefaultConfig(t, v) + require.NoError(t, applyFipsDefaultOverlay(v)) + cfg := loadAndUnmarshalHashConfig(t, v, "SystemDefaults.SecretHasher") + assert.Equal(t, crypto.HashNamePBKDF2, cfg.Hasher.Algorithm) + assert.Equal(t, 290000, v.GetInt("SystemDefaults.SecretHasher.Hasher.Rounds")) + assert.Equal(t, "sha256", v.GetString("SystemDefaults.SecretHasher.Hasher.Hash")) + assert.Empty(t, cfg.Verifiers) +} + +func TestLoadDefaultConfig_FIPSVerifierKeysEmpty(t *testing.T) { + v := testViper(t) + loadBaseDefaultConfig(t, v) + require.NoError(t, applyFipsDefaultOverlay(v)) + assert.Empty(t, v.GetStringSlice("SystemDefaults.PasswordHasher.Verifiers")) + assert.Empty(t, v.GetStringSlice("SystemDefaults.SecretHasher.Verifiers")) +} diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index b026936ed9..90af2131f0 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -890,6 +890,7 @@ SystemDefaults: MinP: 1 # ZITADEL_SYSTEMDEFAULTS_PASSWORDHASHER_LIMITS_SCRYPT_MINP MaxP: 16 # ZITADEL_SYSTEMDEFAULTS_PASSWORDHASHER_LIMITS_SCRYPT_MAXP PBKDF2: + # FIPS mode: MinRounds below 1000 (NIST SP 800-132) logs a startup warning; primary Hasher Rounds must be >= 1000. MinRounds: 1000 # ZITADEL_SYSTEMDEFAULTS_PASSWORDHASHER_LIMITS_PBKDF2_MINROUNDS MaxRounds: 10000000 # ZITADEL_SYSTEMDEFAULTS_PASSWORDHASHER_LIMITS_PBKDF2_MAXROUNDS Sha2: @@ -966,6 +967,7 @@ SystemDefaults: MinP: 1 # ZITADEL_SYSTEMDEFAULTS_SECRETHASHER_LIMITS_SCRYPT_MINP MaxP: 16 # ZITADEL_SYSTEMDEFAULTS_SECRETHASHER_LIMITS_SCRYPT_MAXP PBKDF2: + # FIPS mode: MinRounds below 1000 (NIST SP 800-132) logs a startup warning; primary Hasher Rounds must be >= 1000. MinRounds: 1000 # ZITADEL_SYSTEMDEFAULTS_SECRETHASHER_LIMITS_PBKDF2_MINROUNDS MaxRounds: 10000000 # ZITADEL_SYSTEMDEFAULTS_SECRETHASHER_LIMITS_PBKDF2_MAXROUNDS Sha2: diff --git a/cmd/defaults_fips.yaml b/cmd/defaults_fips.yaml new file mode 100644 index 0000000000..bbd8dcde5a --- /dev/null +++ b/cmd/defaults_fips.yaml @@ -0,0 +1,14 @@ +# FIPS runtime overlay: merged on top of defaults.yaml when FIPS 140-3 mode is enabled (GODEBUG=fips140=on|only). +SystemDefaults: + PasswordHasher: + Hasher: + Algorithm: pbkdf2 # ZITADEL_SYSTEMDEFAULTS_PASSWORDHASHER_HASHER_ALGORITHM + Rounds: 290000 # ZITADEL_SYSTEMDEFAULTS_PASSWORDHASHER_HASHER_ROUNDS + Hash: sha256 # ZITADEL_SYSTEMDEFAULTS_PASSWORDHASHER_HASHER_HASH + Verifiers: [] # ZITADEL_SYSTEMDEFAULTS_PASSWORDHASHER_VERIFIERS + SecretHasher: + Hasher: + Algorithm: pbkdf2 # ZITADEL_SYSTEMDEFAULTS_SECRETHASHER_HASHER_ALGORITHM + Rounds: 290000 # ZITADEL_SYSTEMDEFAULTS_SECRETHASHER_HASHER_ROUNDS + Hash: sha256 # ZITADEL_SYSTEMDEFAULTS_SECRETHASHER_HASHER_HASH + Verifiers: [] # ZITADEL_SYSTEMDEFAULTS_SECRETHASHER_VERIFIERS diff --git a/cmd/zitadel.go b/cmd/zitadel.go index c5d87ee45c..bef95f1894 100644 --- a/cmd/zitadel.go +++ b/cmd/zitadel.go @@ -3,6 +3,7 @@ package cmd import ( "bytes" "context" + "crypto/fips140" _ "embed" "errors" "io" @@ -27,6 +28,9 @@ var ( //go:embed defaults.yaml defaultConfig []byte + + //go:embed defaults_fips.yaml + defaultFipsConfig []byte ) func New(out io.Writer, in io.Reader, args []string, server chan<- *start.Server) *cobra.Command { @@ -45,7 +49,7 @@ func New(out io.Writer, in io.Reader, args []string, server chan<- *start.Server viper.SetEnvPrefix("ZITADEL") viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) viper.SetConfigType("yaml") - err := viper.ReadConfig(bytes.NewBuffer(defaultConfig)) + err := loadDefaultConfig() logging.OnError(context.Background(), err).Fatal("unable to read default config") cobra.OnInitialize(initConfig) @@ -68,6 +72,28 @@ func New(out io.Writer, in io.Reader, args []string, server chan<- *start.Server return cmd } +func loadDefaultConfig() error { + return loadDefaultConfigInto(viper.GetViper()) +} + +func loadDefaultConfigInto(v *viper.Viper) error { + if err := v.ReadConfig(bytes.NewBuffer(defaultConfig)); err != nil { + return err + } + return mergeFipsDefaultConfig(v) +} + +func mergeFipsDefaultConfig(v *viper.Viper) error { + if !fips140.Enabled() { + return nil + } + return applyFipsDefaultOverlay(v) +} + +func applyFipsDefaultOverlay(v *viper.Viper) error { + return v.MergeConfig(bytes.NewBuffer(defaultFipsConfig)) +} + func initConfig() { for _, file := range configFiles { viper.SetConfigFile(file) diff --git a/go.mod b/go.mod index b66aaddfd8..4066182999 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module github.com/zitadel/zitadel go 1.25.0 -toolchain go1.25.8 +toolchain go1.25.11 require ( cloud.google.com/go/profiler v0.6.0 diff --git a/internal/crypto/fips140.go b/internal/crypto/fips140.go new file mode 100644 index 0000000000..bde2380681 --- /dev/null +++ b/internal/crypto/fips140.go @@ -0,0 +1,99 @@ +package crypto + +import ( + "crypto/fips140" + "fmt" + "log/slog" + + "github.com/zitadel/zitadel/backend/v3/instrumentation/logging" +) + +// fipsPBKDF2MinIterations is the minimum PBKDF2 iteration count per NIST SP 800-132 ยง5.2. +const fipsPBKDF2MinIterations uint32 = 1000 + +// fips140Mode reports whether FIPS 140-3 application policies are enforced. +// Defaults to crypto/fips140.Enabled; tests in this package may replace it. +var fips140Mode = fips140.Enabled + +func fips140Enabled() bool { + return fips140Mode() +} + +// IsFIPSCompliant reports whether the algorithm is approved for password +// hashing/verification under FIPS 140-3 (NIST SP 800-132). +func (n HashName) IsFIPSCompliant() bool { + return n == HashNamePBKDF2 +} + +// IsFIPSCompliant reports whether the hash mode is an approved PBKDF2 PRF +// under FIPS 140-3 (SHA-2 family; SHA-1/SHA-224 rejected for new use). +func (m HashMode) IsFIPSCompliant() bool { + return m == HashModeSHA256 || m == HashModeSHA384 || m == HashModeSHA512 +} + +func nonFIPSVerifiersConfigured(verifiers []HashName) []HashName { + var found []HashName + for _, v := range verifiers { + if !v.IsFIPSCompliant() { + found = append(found, v) + } + } + return found +} + +func validateFIPSPBKDF2Hasher(c HasherConfig) error { + p, hashMode, err := c.pbkdf2Params() + if err != nil { + return fmt.Errorf("decode pbkdf2 hasher for FIPS validation: %w", err) + } + if !hashMode.IsFIPSCompliant() { + return fmt.Errorf( + "application cannot start in uncertified cryptographic state: pbkdf2 hash mode %q is not FIPS 140-3 compliant while FIPS mode is enabled", + hashMode, + ) + } + if p.Rounds < fipsPBKDF2MinIterations { + return fmt.Errorf( + "application cannot start in uncertified cryptographic state: pbkdf2 iteration count %d is below the FIPS minimum of %d while FIPS mode is enabled", + p.Rounds, fipsPBKDF2MinIterations, + ) + } + return nil +} + +func (c *HashConfig) validateFIPS140() error { + if !fips140Enabled() { + return nil + } + + alg := c.Hasher.Algorithm + if !alg.IsFIPSCompliant() { + return fmt.Errorf( + "application cannot start in uncertified cryptographic state: password hasher algorithm %q is not FIPS 140-3 compliant while FIPS mode is enabled", + alg, + ) + } + + if alg == HashNamePBKDF2 { + if err := validateFIPSPBKDF2Hasher(c.Hasher); err != nil { + return err + } + } + + if legacy := nonFIPSVerifiersConfigured(c.Verifiers); len(legacy) > 0 { + logging.New(logging.StreamRuntime).Warn( + "Non-FIPS compliant password verifiers are active for migration. This instance is temporarily non-compliant until these verifiers are disabled", + slog.Any("verifiers", legacy), + ) + } + + if c.Limits.PBKDF2.MinRounds < fipsPBKDF2MinIterations { + logging.New(logging.StreamRuntime).Warn( + "PBKDF2 MinRounds is below the FIPS 140-3 minimum iteration count; imported hashes may use non-compliant cost parameters until limits are raised", + slog.Uint64("min_rounds", uint64(c.Limits.PBKDF2.MinRounds)), + slog.Uint64("fips_minimum", uint64(fipsPBKDF2MinIterations)), + ) + } + + return nil +} diff --git a/internal/crypto/fips140_test.go b/internal/crypto/fips140_test.go new file mode 100644 index 0000000000..4a415cd4a0 --- /dev/null +++ b/internal/crypto/fips140_test.go @@ -0,0 +1,181 @@ +package crypto + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func withFIPS140Enabled(t *testing.T) { + t.Helper() + prev := fips140Mode + fips140Mode = func() bool { return true } + t.Cleanup(func() { fips140Mode = prev }) +} + +func TestHashName_IsFIPSCompliant(t *testing.T) { + tests := []struct { + name HashName + want bool + }{ + {HashNamePBKDF2, true}, + {HashNameArgon2, false}, + {HashNameArgon2i, false}, + {HashNameArgon2id, false}, + {HashNameBcrypt, false}, + {HashNameMd5, false}, + {HashNameMd5Plain, false}, + {HashNameMd5Salted, false}, + {HashNamePHPass, false}, + {HashNameSha2, false}, + {HashNameScrypt, false}, + {HashNameDrupal7, false}, + } + for _, tt := range tests { + t.Run(string(tt.name), func(t *testing.T) { + assert.Equal(t, tt.want, tt.name.IsFIPSCompliant()) + }) + } +} + +func TestHashMode_IsFIPSCompliant(t *testing.T) { + tests := []struct { + mode HashMode + want bool + }{ + {HashModeSHA256, true}, + {HashModeSHA384, true}, + {HashModeSHA512, true}, + {HashModeSHA1, false}, + {HashModeSHA224, false}, + } + for _, tt := range tests { + t.Run(string(tt.mode), func(t *testing.T) { + assert.Equal(t, tt.want, tt.mode.IsFIPSCompliant()) + }) + } +} + +func TestNonFIPSVerifiersConfigured(t *testing.T) { + got := nonFIPSVerifiersConfigured([]HashName{HashNamePBKDF2, HashNameBcrypt, HashNameMd5}) + assert.Equal(t, []HashName{HashNameBcrypt, HashNameMd5}, got) + assert.Equal(t, []HashName{HashNameSha2}, nonFIPSVerifiersConfigured([]HashName{HashNamePBKDF2, HashNameSha2})) + assert.Nil(t, nonFIPSVerifiersConfigured([]HashName{HashNamePBKDF2})) +} + +func TestValidateFIPSPBKDF2Hasher(t *testing.T) { + tests := []struct { + name string + hasher HasherConfig + wantErr string + }{ + { + name: "rounds below minimum", + hasher: HasherConfig{ + Algorithm: HashNamePBKDF2, + Params: map[string]any{ + "Rounds": 999, + "Hash": HashModeSHA256, + }, + }, + wantErr: "iteration count 999 is below the FIPS minimum", + }, + { + name: "rounds at minimum", + hasher: HasherConfig{ + Algorithm: HashNamePBKDF2, + Params: map[string]any{ + "Rounds": 1000, + "Hash": HashModeSHA256, + }, + }, + }, + { + name: "sha1 hash mode", + hasher: HasherConfig{ + Algorithm: HashNamePBKDF2, + Params: map[string]any{ + "Rounds": 10000, + "Hash": HashModeSHA1, + }, + }, + wantErr: "hash mode \"sha1\" is not FIPS 140-3 compliant", + }, + { + name: "sha224 hash mode", + hasher: HasherConfig{ + Algorithm: HashNamePBKDF2, + Params: map[string]any{ + "Rounds": 10000, + "Hash": HashModeSHA224, + }, + }, + wantErr: "hash mode \"sha224\" is not FIPS 140-3 compliant", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateFIPSPBKDF2Hasher(tt.hasher) + if tt.wantErr == "" { + require.NoError(t, err) + return + } + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + }) + } +} + +func TestHashConfig_validateFIPS140(t *testing.T) { + withFIPS140Enabled(t) + + t.Run("bcrypt hasher fails", func(t *testing.T) { + cfg := &HashConfig{ + Hasher: HasherConfig{ + Algorithm: HashNameBcrypt, + Params: map[string]any{"Cost": 12}, + }, + Limits: HashLimitsConfig{ + Bcrypt: BcryptLimitsConfig{MinCost: 10, MaxCost: 16}, + }, + } + err := cfg.validateFIPS140() + require.Error(t, err) + assert.Contains(t, err.Error(), "uncertified cryptographic state") + assert.Contains(t, err.Error(), "bcrypt") + }) + + t.Run("pbkdf2 compliant passes", func(t *testing.T) { + cfg := &HashConfig{ + Hasher: HasherConfig{ + Algorithm: HashNamePBKDF2, + Params: map[string]any{ + "Rounds": 290000, + "Hash": HashModeSHA256, + }, + }, + Limits: HashLimitsConfig{ + PBKDF2: PBKDF2LimitsConfig{MinRounds: 1000, MaxRounds: 10000000}, + }, + } + require.NoError(t, cfg.validateFIPS140()) + }) +} + +func TestHashConfig_NewHasher_FIPSBcryptFails(t *testing.T) { + withFIPS140Enabled(t) + + cfg := &HashConfig{ + Hasher: HasherConfig{ + Algorithm: HashNameBcrypt, + Params: map[string]any{"Cost": 12}, + }, + Limits: HashLimitsConfig{ + Bcrypt: BcryptLimitsConfig{MinCost: 10, MaxCost: 16}, + }, + } + _, err := cfg.NewHasher() + require.Error(t, err) + assert.Contains(t, err.Error(), "uncertified cryptographic state") +} diff --git a/internal/crypto/passwap.go b/internal/crypto/passwap.go index 535059c9c3..7aa6c62bc4 100644 --- a/internal/crypto/passwap.go +++ b/internal/crypto/passwap.go @@ -217,6 +217,9 @@ func (l Drupal7LimitsConfig) validationOpts() *drupal7.ValidationOpts { } func (c *HashConfig) NewHasher() (*Hasher, error) { + if err := c.validateFIPS140(); err != nil { + return nil, err + } verifiers, vPrefixes, err := c.buildVerifiers() if err != nil { return nil, zerrors.ThrowInvalidArgument(err, "CRYPT-sahW9", "password hash config invalid")