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
This commit is contained in:
Tim Möhlmann
2026-06-16 10:16:49 +02:00
committed by GitHub
parent fdafb95a63
commit e94d4c3986
15 changed files with 588 additions and 23 deletions
-1
View File
@@ -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
+120 -19
View File
@@ -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<<EOF" >> "$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 }}
+18
View File
@@ -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
+6 -1
View File
@@ -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\"'"
]
}
+23
View File
@@ -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"]
+13
View File
@@ -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,
+6
View File
@@ -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}`);
+75
View File
@@ -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"))
}
+2
View File
@@ -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:
+14
View File
@@ -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
+27 -1
View File
@@ -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)
+1 -1
View File
@@ -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
+99
View File
@@ -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
}
+181
View File
@@ -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")
}
+3
View File
@@ -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")