mirror of
https://github.com/zitadel/zitadel.git
synced 2026-08-18 17:05:03 -05:00
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:
@@ -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
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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\"'"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user