diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index f6af54863..b9b06b736 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,7 +1,7 @@ { "name": "flagd dev container", // Image details: https://github.com/devcontainers/images/tree/main/src/go - "image": "mcr.microsoft.com/devcontainers/go:1.24", + "image": "mcr.microsoft.com/devcontainers/go:1.27", // Features to add to the dev container. More info: https://containers.dev/features. "features": { "ghcr.io/devcontainers/features/github-cli:1": {}, diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 4d67df540..784e054a8 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -15,6 +15,10 @@ on: - "README.md" - "docs/**" +env: + # The toolchain determines which FIPS snapshots exist, so pin it. + GOTOOLCHAIN: local + jobs: lint: runs-on: ubuntu-latest @@ -63,6 +67,66 @@ jobs: - name: Upload coverage to Codecov uses: codecov/codecov-action@e0b68c6749509c5f83f984dd99a76a1c1a231044 # v4 + fips: + name: FIPS 140-3 + runs-on: ubuntu-latest + env: + GOPATH: /home/runner/work/open-feature/flagd + GOBIN: /home/runner/work/open-feature/flagd/bin + steps: + - name: Checkout repository + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 + - name: Setup go + uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5 + with: + go-version-file: 'flagd/go.mod' + - run: make workspace-init + + # Fails if any tested code path reaches a non-approved algorithm. + - name: Test the FIPS variant with strict enforcement + run: make test-fips + + - name: Build both variants + run: | + set -euo pipefail + make build-flagd + make build-flagd-fips + + - name: The standard build must not claim FIPS + run: | + set -euo pipefail + ./bin/flagd version | grep -q 'build variant: standard' + ./bin/flagd version | grep -q 'FIPS 140-3 mode: disabled' + # The standard build must still run with FIPS mode forced off. + GODEBUG=fips140=off ./bin/flagd version >/dev/null + + - name: The FIPS build must carry and require the certified module + run: | + set -euo pipefail + go version -m ./bin/flagd-fips | grep -q 'GOFIPS140=v1\.0\.0' + go version -m ./bin/flagd-fips | grep -q 'DefaultGODEBUG=fips140=on' + go version -m ./bin/flagd-fips | grep -q -- '-tags=fips140' + ./bin/flagd-fips version | grep -q 'build variant: fips' + ./bin/flagd-fips version | grep -q 'FIPS 140-3 mode: enabled' + # Enforcement is unconditional: no flag can turn it off. + if GODEBUG=fips140=off ./bin/flagd-fips start -f file:./config/samples/example_flags.flagd.json; then + echo "::error::the FIPS build started with FIPS mode disabled"; exit 1 + fi + + - name: A fips140-tagged build without GOFIPS140 must refuse to start + run: | + set -euo pipefail + go build -tags fips140 -o /tmp/flagd-mismatch ./flagd + if /tmp/flagd-mismatch start -f file:./config/samples/example_flags.flagd.json; then + echo "::error::a mislabelled FIPS build started"; exit 1 + fi + + - name: Check the cryptographic dependency closure has not drifted + run: | + make fips-closure + git diff --exit-code docs/reference/fips-crypto-closure.txt \ + || { echo "::error::cryptographic dependency closure changed; review docs/reference/fips-140-3.md and commit the regenerated closure"; exit 1; } + docker-local: runs-on: ubuntu-latest diff --git a/.github/workflows/release-please.yaml b/.github/workflows/release-please.yaml index cb53ccc0b..45c7fc9cb 100644 --- a/.github/workflows/release-please.yaml +++ b/.github/workflows/release-please.yaml @@ -8,9 +8,11 @@ env: PUBLISHABLE_ITEMS: '["flagd","flagd-proxy"]' REGISTRY: ghcr.io REPO_OWNER: ${{ github.repository_owner }} - DEFAULT_GO_VERSION: '~1.25' + DEFAULT_GO_VERSION: '~1.27' PUBLIC_KEY_FILE: publicKey.pub GOPRIVATE: buf.build/gen/go + # The toolchain determines which FIPS snapshots exist, so pin it. + GOTOOLCHAIN: local name: Release Please jobs: @@ -120,12 +122,34 @@ jobs: VERSION=${{ env.VERSION }} COMMIT=${{ github.sha }} DATE=${{ needs.release-please.outputs.date }} + - name: Build FIPS variant + id: build-fips + uses: docker/build-push-action@4a13e500e55cf31b7a5d59a38ab2040ab0f42f56 # v5 + with: + builder: ${{ steps.buildx.outputs.name }} + context: . + file: ./${{ matrix.path }}/build.Dockerfile + platforms: linux/amd64,linux/arm64 + provenance: mode=max + sbom: true + push: true + tags: | + ${{ env.REGISTRY }}/${{ env.REPO_OWNER }}/${{ matrix.path }}:latest-fips + ${{ env.REGISTRY }}/${{ env.REPO_OWNER }}/${{ matrix.path }}:${{ env.VERSION }}-fips + labels: ${{ steps.meta.outputs.labels }} + build-args: | + VERSION=${{ env.VERSION }} + COMMIT=${{ github.sha }} + DATE=${{ needs.release-please.outputs.date }} + FIPS=on + - name: Install Cosign uses: sigstore/cosign-installer@e1523de7571e31dbe865fd2e80c5c7c23ae71eb4 - - name: Sign the image + - name: Sign the images run: | cosign sign --yes --key env://COSIGN_PRIVATE_KEY ${{ env.REGISTRY }}/${{ env.REPO_OWNER }}/${{ matrix.path }}@${{ steps.build.outputs.digest }} + cosign sign --yes --key env://COSIGN_PRIVATE_KEY ${{ env.REGISTRY }}/${{ env.REPO_OWNER }}/${{ matrix.path }}@${{ steps.build-fips.outputs.digest }} cosign public-key --key env://COSIGN_PRIVATE_KEY --outfile ${{ env.PUBLIC_KEY_FILE }} env: COSIGN_PRIVATE_KEY: ${{secrets.COSIGN_PRIVATE_KEY}} @@ -189,10 +213,6 @@ jobs: run: | env CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build ${{ env.BUILD_ARGS }} -o ./${{ matrix.path }}_linux_x86_64 ./${{ matrix.path }}/main.go tar -cvzf ${{ matrix.path }}_${{ env.VERSION_NO_PREFIX }}_Linux_x86_64.tar.gz ./${{ matrix.path }}_linux_x86_64 ./LICENSE ./CHANGELOG.md ./README.md ./sbom.xml - - name: build linux i386 - run: | - env CGO_ENABLED=0 GOOS=linux GOARCH=386 go build ${{ env.BUILD_ARGS }} -o ./${{ matrix.path }}_linux_i386 ./${{ matrix.path }}/main.go - tar -cvzf ${{ matrix.path }}_${{ env.VERSION_NO_PREFIX }}_Linux_i386.tar.gz ./${{ matrix.path }}_linux_i386 ./LICENSE ./CHANGELOG.md ./README.md ./sbom.xml # Windows artifacts use .zip archive - name: build windows x86_64 run: | @@ -202,6 +222,36 @@ jobs: run: | env CGO_ENABLED=0 GOOS=windows GOARCH=386 go build ${{ env.BUILD_ARGS }} -o ./${{ matrix.path }}_windows_i386 ./${{ matrix.path }}/main.go zip -r ${{ matrix.path }}_${{ env.VERSION_NO_PREFIX }}_Windows_i386.zip ./${{ matrix.path }}_windows_i386 ./LICENSE ./CHANGELOG.md ./README.md ./sbom.xml + + # FIPS variant. Fewer targets than the standard build: FIPS mode is + # unsupported on windows/386, and linux/386 is not a validated operating + # environment for the module. + - name: build FIPS variant + env: + # The CMVP-certified Go Cryptographic Module v1.0.0 (certificate #5247). + GOFIPS140: v1.0.0 + run: | + set -euo pipefail + build_fips() { + goos="$1"; goarch="$2"; label="$3"; format="$4" + bin="./${{ matrix.path }}-fips_${goos}_${goarch}" + env CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build ${{ env.BUILD_ARGS }} -tags fips140 -o "$bin" ./${{ matrix.path }}/main.go + # The toolchain adds fips140v1.0 only when GOFIPS140 is set, so both + # tags together prove the binary built as the FIPS variant. + go version -m "$bin" | grep -q -- '-tags=fips140,fips140v1.0' \ + || { echo "::error::$bin is not a FIPS 140-3 build"; exit 1; } + archive="${{ matrix.path }}-fips_${{ env.VERSION_NO_PREFIX }}_${label}" + if [ "$format" = tar ]; then + tar -cvzf "${archive}.tar.gz" "$bin" ./LICENSE ./CHANGELOG.md ./README.md ./sbom.xml + else + zip -r "${archive}.zip" "$bin" ./LICENSE ./CHANGELOG.md ./README.md ./sbom.xml + fi + } + build_fips darwin arm64 Darwin_arm64 tar + build_fips darwin amd64 Darwin_x86_64 tar + build_fips linux arm64 Linux_arm64 tar + build_fips linux amd64 Linux_x86_64 tar + build_fips windows amd64 Windows_x86_64 zip # Bundle release artifacts - name: Bundle release assets uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1 diff --git a/.golangci.yml b/.golangci.yml index d985b156b..67a2220bf 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,8 +1,28 @@ version: "2" linters: + enable: + - depguard settings: funlen: statements: 50 + depguard: + rules: + # These sit outside the FIPS 140-3 validated module boundary. + # See docs/reference/fips-140-3.md. + fips-140-3: + files: + - "!$test" + deny: + - pkg: golang.org/x/crypto + desc: use the stdlib crypto/* equivalent so the call routes through the validated Go Cryptographic Module + - pkg: crypto/md5 + desc: MD5 is not an approved algorithm and is not part of the validated module + - pkg: crypto/sha1 + desc: SHA-1 is not an approved algorithm and is not part of the validated module + - pkg: crypto/des + desc: DES/3DES is not an approved algorithm + - pkg: crypto/rc4 + desc: RC4 is not an approved algorithm exclusions: generated: lax presets: diff --git a/.goreleaser.yaml b/.goreleaser.yaml deleted file mode 100644 index ac5ef5dd8..000000000 --- a/.goreleaser.yaml +++ /dev/null @@ -1,31 +0,0 @@ -# This is an example .goreleaser.yml file with some sensible defaults. -# Make sure to check the documentation at https://goreleaser.com -before: - hooks: - # You may remove this if you don't use go modules. - - go mod tidy - # you may remove this if you don't need go generate - - go generate ./... -builds: - - env: - - CGO_ENABLED=0 - goos: - - linux - - windows - - darwin -archives: - - name_template: >- - {{ .ProjectName }}_{{ .Version }}_{{- title .Os }}_{{- if eq .Arch "amd64" }}x86_64{{- else if eq .Arch "386" }}i386{{- else }}{{ .Arch }}{{ end }} -# Generate SBOM per each archive -sboms: - - artifacts: archive -checksum: - name_template: 'checksums.txt' -snapshot: - name_template: "{{ incpatch .Version }}-next" -changelog: - sort: asc - filters: - exclude: - - '^docs:' - - '^test:' diff --git a/Makefile b/Makefile index ab93b418d..e9d1d6f53 100644 --- a/Makefile +++ b/Makefile @@ -3,6 +3,13 @@ PREFIX=/usr/local PUBLIC_JSON_SCHEMA_DIR=docs/schema/v0/ ALL_GO_MOD_DIRS := $(shell find . -path ./test/integration -prune -o -type f -name 'go.mod' -exec dirname {} \; | sort) +# The FIPS variant builds against the CMVP-certified Go Cryptographic Module +# v1.0.0 (certificate #5247). The literal version is required; the +# "certified"/"inprocess" aliases vary by toolchain. The fips140 tag makes the +# binary require that module at startup rather than merely report on it. +FIPS_ENV := GOFIPS140=v1.0.0 CGO_ENABLED=0 +FIPS_TAGS := fips140 + FLAGD_DEV_NAMESPACE ?= flagd-dev ZD_TEST_NAMESPACE_FLAGD_PROXY ?= flagd-proxy-zd-test ZD_TEST_NAMESPACE ?= flagd-zd-test @@ -39,6 +46,11 @@ build: workspace-init # default to flagd make build-flagd build-flagd: go build -ldflags "-X main.version=dev -X main.commit=$$(git rev-parse --short HEAD) -X main.date=$$(date +%FT%TZ)" -o ./bin/flagd ./flagd + +.PHONY: build-flagd-fips +build-flagd-fips: + $(FIPS_ENV) go build -tags $(FIPS_TAGS) -ldflags "-X main.version=dev -X main.commit=$$(git rev-parse --short HEAD) -X main.date=$$(date +%FT%TZ)" -o ./bin/flagd-fips ./flagd + go version -m ./bin/flagd-fips | grep -E 'GOFIPS140=|-tags=' .PHONY: test test: test-core test-flagd test-flagd-proxy test-core: @@ -47,6 +59,27 @@ test-flagd: go test -race -covermode=atomic -cover -short ./flagd/pkg/... -coverprofile=flagd-coverage.out test-flagd-proxy: go test -race -covermode=atomic -cover -short ./flagd-proxy/pkg/... -coverprofile=flagd-proxy-coverage.out +# fips140=only makes non-approved algorithms error or panic, so this fails if a +# tested path reaches MD5, SHA-1, RC4, 3DES or ChaCha20-Poly1305. Test mode only; +# released binaries run with fips140=on. +# +# -exec applies the GODEBUG to the test binaries only. Setting it in the +# environment would also apply it to the go command, whose module fetches over +# TLS negotiate X25519 and fail under fips140=only. +.PHONY: test-fips +test-fips: + $(FIPS_ENV) go test -tags $(FIPS_TAGS) -short -count=1 -exec 'env GODEBUG=fips140=only' ./core/... ./flagd/... ./flagd-proxy/... + +# Regenerate the crypto dependency closure recorded in the FIPS docs. Pinned to +# linux/amd64: the closure is platform-dependent, so an unpinned run would +# differ between a developer machine and CI. +.PHONY: fips-closure +fips-closure: + @$(FIPS_ENV) GOOS=linux GOARCH=amd64 go list -tags $(FIPS_TAGS) -deps ./flagd ./flagd-proxy \ + | grep -E '^(crypto|golang\.org/x/crypto)($$|/)|/crypto($$|/)' \ + | sort -u > docs/reference/fips-crypto-closure.txt + @echo "wrote docs/reference/fips-crypto-closure.txt" + flagd-benchmark-test: go test -bench=Bench -short -benchtime=5s -benchmem ./core/... | tee benchmark.txt flagd-integration-test-harness: @@ -74,10 +107,10 @@ uninstall: rm /etc/systemd/system/flagd.service rm -f $(DESTDIR)$(PREFIX)/bin/flagd lint: - go install -v github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.7.2 + go install -v github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.2 $(foreach module, $(ALL_GO_MOD_DIRS), ${GOPATH}/bin/golangci-lint run $(module)/...;) lint-fix: - go install -v github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.7.2 + go install -v github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.2 $(foreach module, $(ALL_GO_MOD_DIRS), ${GOPATH}/bin/golangci-lint run --fix $(module)/...;) install-mockgen: go install go.uber.org/mock/mockgen@v0.4.0 diff --git a/core/go.mod b/core/go.mod index f5fad884f..d5035b77e 100644 --- a/core/go.mod +++ b/core/go.mod @@ -1,6 +1,6 @@ module github.com/open-feature/flagd/core -go 1.25.0 +go 1.26.0 require ( buf.build/gen/go/open-feature/flagd/grpc/go v1.6.1-20260217192757-1388a552fc3c.1 @@ -29,7 +29,6 @@ require ( go.uber.org/mock v0.5.2 go.uber.org/zap v1.27.0 gocloud.dev v0.42.0 - golang.org/x/crypto v0.52.0 golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac golang.org/x/mod v0.35.0 golang.org/x/oauth2 v0.36.0 @@ -148,6 +147,7 @@ require ( go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect + golang.org/x/crypto v0.52.0 // indirect golang.org/x/net v0.55.0 // indirect golang.org/x/sys v0.45.0 // indirect golang.org/x/term v0.43.0 // indirect diff --git a/core/pkg/fips/fips.go b/core/pkg/fips/fips.go new file mode 100644 index 000000000..73389461f --- /dev/null +++ b/core/pkg/fips/fips.go @@ -0,0 +1,97 @@ +// Package fips reports whether this binary is running the CMVP-certified Go +// Cryptographic Module in FIPS 140-3 approved mode. +// +// flagd is released in two variants. The standard build uses the ordinary Go +// cryptography and only reports its state. The FIPS build, produced with +// GOFIPS140=v1.0.0 and -tags fips140, requires the certified module and refuses +// to run without it; see Check. +package fips + +import ( + "crypto/fips140" + "errors" + "fmt" + "runtime/debug" +) + +// CertifiedModuleVersion is the formal module version reported by +// crypto/fips140.Version for CMVP certificate #5247. +const CertifiedModuleVersion = "v1.0.0" + +// CertifiedModuleSnapshot is the exact frozen snapshot GOFIPS140 records for +// that module. Matched in full rather than as a v1.0.0 prefix, so a different +// snapshot cannot be reported as the certified one. +const CertifiedModuleSnapshot = "v1.0.0-c2097c7c" + +// Status describes the FIPS 140-3 state of this process. +type Status struct { + Enabled bool + + // ModuleVersion is "latest" unless the binary was built with GOFIPS140, so + // Enabled alone does not prove the certified module is in use. + ModuleVersion string + + // BuildSetting is the GOFIPS140 value recorded at build time, if any. + BuildSetting string +} + +// Current inspects the running binary and returns its FIPS 140-3 status. +func Current() Status { + s := Status{ + Enabled: fips140.Enabled(), + ModuleVersion: fips140.Version(), + } + if info, ok := debug.ReadBuildInfo(); ok { + for _, setting := range info.Settings { + if setting.Key == "GOFIPS140" { + s.BuildSetting = setting.Value + break + } + } + } + return s +} + +// BuiltForFIPS reports whether the certified module was compiled in. +func (s Status) BuiltForFIPS() bool { + return s.BuildSetting == CertifiedModuleSnapshot +} + +// Certified reports whether the certified module is both compiled in and active. +func (s Status) Certified() bool { + return s.Enabled && s.BuiltForFIPS() +} + +// Check reports whether this build is running the cryptography it claims. +// It always returns nil in the standard build. In the FIPS build it returns an +// error unless the certified module is compiled in and active, so a binary +// carrying the fips140 tag can never serve traffic on unvalidated cryptography. +func Check() error { + if !RequireCertified { + return nil + } + s := Current() + switch { + case s.Certified(): + return nil + case !s.BuiltForFIPS(): + return fmt.Errorf("built with -tags fips140 but not against the certified module "+ + "(GOFIPS140=%q, want %q); rebuild with GOFIPS140=%s", + s.BuildSetting, CertifiedModuleSnapshot, CertifiedModuleVersion) + default: + return errors.New("FIPS 140-3 mode is disabled at runtime; remove fips140=off from GODEBUG") + } +} + +func (s Status) String() string { + switch { + case s.Certified(): + return fmt.Sprintf("enabled (Go Cryptographic Module %s, GOFIPS140=%s)", s.ModuleVersion, s.BuildSetting) + case s.BuiltForFIPS(): + return fmt.Sprintf("disabled (built with GOFIPS140=%s but GODEBUG=fips140=off at startup)", s.BuildSetting) + case s.Enabled: + return fmt.Sprintf("enabled, uncertified (Go Cryptographic Module %s; binary not built with GOFIPS140)", s.ModuleVersion) + default: + return "disabled (binary not built with GOFIPS140)" + } +} diff --git a/core/pkg/fips/fips_test.go b/core/pkg/fips/fips_test.go new file mode 100644 index 000000000..ecfefef62 --- /dev/null +++ b/core/pkg/fips/fips_test.go @@ -0,0 +1,94 @@ +package fips + +import ( + "crypto/fips140" + "strings" + "testing" +) + +func TestStatusClassification(t *testing.T) { + tests := []struct { + name string + status Status + builtForFIPS bool + certified bool + wantInString string + }{ + { + name: "released FIPS artifact in approved mode", + status: Status{Enabled: true, ModuleVersion: "v1.0.0", BuildSetting: CertifiedModuleSnapshot}, + builtForFIPS: true, + certified: true, + wantInString: "enabled (Go Cryptographic Module v1.0.0", + }, + { + name: "FIPS artifact started with GODEBUG=fips140=off", + status: Status{Enabled: false, ModuleVersion: "v1.0.0", BuildSetting: CertifiedModuleSnapshot}, + builtForFIPS: true, + wantInString: "GODEBUG=fips140=off at startup", + }, + { + name: "a different frozen module snapshot is not the certified one", + status: Status{Enabled: true, ModuleVersion: "v1.0.0", BuildSetting: "v1.0.0-deadbeef"}, + wantInString: "uncertified", + }, + { + name: "standard build with FIPS forced on at runtime", + status: Status{Enabled: true, ModuleVersion: "latest"}, + wantInString: "uncertified", + }, + { + name: "standard build", + status: Status{Enabled: false, ModuleVersion: "latest"}, + wantInString: "disabled (binary not built with GOFIPS140)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.status.BuiltForFIPS(); got != tt.builtForFIPS { + t.Errorf("BuiltForFIPS() = %v, want %v", got, tt.builtForFIPS) + } + if got := tt.status.Certified(); got != tt.certified { + t.Errorf("Certified() = %v, want %v", got, tt.certified) + } + if got := tt.status.String(); !strings.Contains(got, tt.wantInString) { + t.Errorf("String() = %q, want it to contain %q", got, tt.wantInString) + } + }) + } +} + +// TestCurrentMatchesRuntime guards the wiring between Current and the runtime, +// so the reported state can never drift from the actual FIPS mode. +func TestCurrentMatchesRuntime(t *testing.T) { + got := Current() + if got.Enabled != fips140.Enabled() { + t.Errorf("Current().Enabled = %v, want %v", got.Enabled, fips140.Enabled()) + } + if got.ModuleVersion != fips140.Version() { + t.Errorf("Current().ModuleVersion = %q, want %q", got.ModuleVersion, fips140.Version()) + } +} + +// TestCheckMatchesVariant runs under both build variants: the standard build +// must never refuse, and the FIPS build must refuse exactly when the certified +// module is not active. +func TestCheckMatchesVariant(t *testing.T) { + err := Check() + if !RequireCertified { + if Variant != "standard" { + t.Errorf("Variant = %q, want %q", Variant, "standard") + } + if err != nil { + t.Errorf("standard build: Check() = %v, want nil", err) + } + return + } + if Variant != "fips" { + t.Errorf("Variant = %q, want %q", Variant, "fips") + } + if certified := Current().Certified(); certified != (err == nil) { + t.Errorf("Check() = %v but Certified() = %v; they must agree", err, certified) + } +} diff --git a/core/pkg/fips/variant_fips.go b/core/pkg/fips/variant_fips.go new file mode 100644 index 000000000..ca1579cca --- /dev/null +++ b/core/pkg/fips/variant_fips.go @@ -0,0 +1,9 @@ +//go:build fips140 + +package fips + +// Variant names this build for logs and `flagd version`. +const Variant = "fips" + +// RequireCertified makes Check fail unless the certified module is active. +const RequireCertified = true diff --git a/core/pkg/fips/variant_standard.go b/core/pkg/fips/variant_standard.go new file mode 100644 index 000000000..f09fd7600 --- /dev/null +++ b/core/pkg/fips/variant_standard.go @@ -0,0 +1,10 @@ +//go:build !fips140 + +package fips + +// Variant names this build for logs and `flagd version`. +const Variant = "standard" + +// RequireCertified is false: the standard build reports FIPS state but does not +// require it. +const RequireCertified = false diff --git a/core/pkg/utils/hash.go b/core/pkg/utils/hash.go index e5f0c29e9..603c3ac77 100644 --- a/core/pkg/utils/hash.go +++ b/core/pkg/utils/hash.go @@ -2,15 +2,17 @@ package utils import ( "bytes" + "crypto/sha3" "encoding/base64" "encoding/json" + "hash" "io" - - "golang.org/x/crypto/sha3" //nolint:gosec ) +// GenerateSha returns a SHA3-256 digest of the canonicalized body. func GenerateSha(body []byte) string { - hasher := sha3.New256() + // hash.Hash rather than *sha3.SHA3: its Write never returns an error. + var hasher hash.Hash = sha3.New256() hasher.Write(canonicalize(body)) return base64.URLEncoding.EncodeToString(hasher.Sum(nil)) } diff --git a/docs/reference/fips-140-3.md b/docs/reference/fips-140-3.md new file mode 100644 index 000000000..5774bb0b8 --- /dev/null +++ b/docs/reference/fips-140-3.md @@ -0,0 +1,173 @@ +--- +description: the flagd FIPS 140-3 build variant, how to verify it, and what it restricts +--- + +# FIPS 140-3 + +flagd is released in two variants. + +The **standard** build is the default and uses the ordinary Go cryptography. +The **FIPS** build is compiled against the Go Cryptographic Module v1.0.0, which holds NIST CMVP validation certificate #5247 and CAVP certificate A6650, and runs it in the FIPS 140-3 approved mode. +Both variants are published for flagd and flagd-proxy on every release. + +The validation covers the Go Cryptographic Module. +flagd consumes the module and has not been submitted to CMVP. +A FIPS-mode deployment may still need further controls to satisfy FedRAMP, DoD SRG, CMMC or a similar programme; those determinations depend on the whole system and its assessment. + +The scope is cryptography performed inside the flagd process. +It excludes TLS terminated by a proxy or service mesh in front of flagd, the platform and container runtime, and operator-supplied key material. +flagd performs no authentication or authorization on any endpoint and expects to run inside a trust boundary. + +## Getting the FIPS build + +Container images carry a `-fips` tag suffix: + +```shell +docker pull ghcr.io/open-feature/flagd:latest-fips +``` + +Release archives carry it in the product position, so globs that match the standard artifacts do not pick up the FIPS ones: + +```text +flagd_0.16.2_Linux_x86_64.tar.gz # standard +flagd-fips_0.16.2_Linux_x86_64.tar.gz # FIPS +``` + +To build one: + +```shell +GOFIPS140=v1.0.0 CGO_ENABLED=0 go build -tags fips140 -o ./bin/flagd-fips ./flagd +``` + +`GOFIPS140=v1.0.0` selects the frozen, certified module snapshot and makes the binary default to `GODEBUG=fips140=on`. +Use the literal version: the `certified` and `inprocess` aliases resolve differently per Go toolchain, and on Go 1.26 `inprocess` points at module v1.26.0, which is still pending CMVP review. + +The `fips140` build tag is what makes the binary *require* that module rather than merely report on it. +The two belong together, and a binary built with the tag but without `GOFIPS140` refuses to start rather than presenting itself as a FIPS build. + +## Verifying + +The build settings are recorded in the binary's `runtime/debug.BuildInfo` and survive `-trimpath` and `-ldflags "-s -w"`. + +```shell +go version -m ./bin/flagd-fips | grep -E 'GOFIPS140|DefaultGODEBUG|tags' +``` + +```console +build -tags=fips140,fips140v1.0 +build DefaultGODEBUG=fips140=on +build GOFIPS140=v1.0.0-c2097c7c +``` + +`v1.0.0-c2097c7c` uniquely identifies the module snapshot. +`fips140` is flagd's own tag; `fips140v1.0` is added by the Go toolchain. + +The running process reports the same state, both from the `version` command and in its startup log. + +```shell +flagd version +``` + +```console +flagd: v0.16.2 (a1b2c3d), built at: 2026-08-28 +build variant: fips +FIPS 140-3 mode: enabled (Go Cryptographic Module v1.0.0, GOFIPS140=v1.0.0-c2097c7c) +``` + +The standard build reports `build variant: standard` and `FIPS 140-3 mode: disabled`. + +## Enforcement + +FIPS mode is fixed at process start and is turned off with `GODEBUG=fips140=off`. +A FIPS build started that way refuses to run, so a stray `GODEBUG` cannot quietly drop a deployment out of approved mode. +There is no flag to override this: a binary that carries the `fips140` tag either runs the certified module or does not run. + +The standard build performs no such check and is unaffected by `GODEBUG=fips140=off`. + +## TLS restrictions + +These apply to the FIPS build only. +In FIPS mode `crypto/tls` will not negotiate a protocol version, cipher suite, curve or signature algorithm outside the approved set. + +| Parameter | Permitted | +| ---------------------- | ------------------------------------------------------------------------------------------------------------- | +| Protocol versions | TLS 1.2, TLS 1.3 | +| Key exchange | `X25519MLKEM768`, `SecP256r1MLKEM768`, `SecP384r1MLKEM1024`, P-256, P-384, P-521 | +| TLS 1.3 cipher suites | `TLS_AES_128_GCM_SHA256`, `TLS_AES_256_GCM_SHA384` | +| TLS 1.2 cipher suites | `TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256`, `TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384`, `TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256`, `TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384`, `TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256`, `TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256` | +| Peer certificates | RSA 2048 bits or larger, ECDSA P-256/P-384/P-521, Ed25519 | + +Excluded: SHA-1, 3DES, RC4, ChaCha20-Poly1305, plain X25519, RSA PKCS #1 v1.5 key transport, TLS 1.0 and TLS 1.1. + +!!! warning "Moving from the standard build to the FIPS build" + + Clients that only offer ChaCha20-Poly1305 or plain X25519 will fail the handshake. + Server certificates with RSA keys under 2048 bits are rejected. + Check your clients and certificates before switching. + + This applies to flagd's outbound sync connections as well as to the TLS it terminates. + A flag source, or a Kubernetes API server, that negotiates outside the approved set will not be reachable from the FIPS build. + +The policy filter lives in `crypto/tls`, which sits outside the validated module boundary and tracks the Go toolchain version. +flagd pins its toolchain so the negotiable set stays fixed across releases. + +## Platform support + +Go's FIPS 140-3 mode is unavailable on `wasm`, `windows/386`, `openbsd` and `aix`. +A `windows/386` build compiles but panics at startup, so that binary is no longer released. +`linux/386` is no longer released either. FIPS mode runs there, but 32-bit x86 Linux is not among the operating environments the module was validated on, so a build for it could not carry the same claim. + +## Cryptography used by flagd + +These are security functions, all serviced by the validated module. + +| Algorithm | Purpose | Location | +| ------------------------------------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| TLS 1.2/1.3 (AES-GCM, ECDHE, HKDF, HMAC) | Evaluation and sync transport security | `flagd/pkg/service/flag-evaluation/connect_service.go`, `flagd/pkg/service/flag-sync/sync_service.go`, `core/pkg/sync/grpc/credentials/builder.go` | +| ECDSA, RSA-PSS, RSA PKCS #1 v1.5, Ed25519 | TLS certificate and signature verification | `crypto/tls`, `crypto/x509` | +| CTR_DRBG | Random number generation | `crypto/rand` | + +The remaining uses are not security functions, so no approved-algorithm requirement applies to them. + +| Algorithm | Purpose | Location | +| --------------- | --------------------------------------------------- | --------------------------------------------------------------- | +| SHA3-256 | Detecting whether a fetched flag configuration changed | `core/pkg/utils/hash.go` | +| SHA-256 | OFREP SSE `ETag` fingerprint | `flagd/pkg/service/flag-evaluation/ofrep/sse/tracker.go` | +| MurmurHash3-32 | Fractional rollout bucketing | `core/pkg/evaluator/fractional.go` | +| FNV-1a 32 | Poll interval jitter offset | `core/pkg/sync/internal/polling/poller.go` | +| UUIDv4 | Default internal flag set identifier | `core/pkg/store/query.go` | + +SHA3-256 and SHA-256 are approved algorithms and come from the validated module; flagd uses them for change detection and HTTP cache validation. + +MurmurHash3 assigns an evaluation context to a bucket for percentage-based rollouts. +The [fractional operation specification](./specifications/custom-operations/fractional-operation-spec.md) mandates it so that every flagd provider, in every language, assigns the same unit to the same bucket. +Predicting or colliding it reveals only which variant a context receives, which evaluating the flag already tells you. + +FNV-1a spreads polling across the configured interval to avoid a thundering herd. +UUIDv4 is an internal index key that is never exposed externally, and its entropy comes from `crypto/rand`. + +## Non-approved algorithms in the dependency tree + +`core/pkg/sync/blob/blob_sync.go` links the `gocloud.dev` drivers for S3, GCS and Azure Blob so those sync sources work out of the box. +Those SDKs pull non-approved primitives into the link closure: + +- `golang.org/x/crypto/pkcs12` and its RC2 implementation, via `azidentity` +- `golang.org/x/crypto/chacha20poly1305`, via `google/s2a-go` +- `crypto/md5` and `crypto/sha1`, via gocloud's `Content-MD5` handling, the AWS checksum and SSO credential paths, and Azure AD MSAL + +The standard library contributes `crypto/des`, `crypto/rc4`, `crypto/sha1` and `crypto/md5` on its own account, through `crypto/tls` and `crypto/x509` legacy parsing, plus `crypto/mldsa` for post-quantum certificate signatures. +The FIPS policy filter keeps that set out of negotiation. + +flagd calls none of these for its own security functions. +They run only on cloud provider credential and object integrity paths, where the SDKs need them for protocol compatibility, and the providers offer FIPS-validated and FedRAMP-authorized endpoints. +Data in transit on those paths is protected by TLS from the validated module. + +The full closure is recorded in [`fips-crypto-closure.txt`](https://github.com/open-feature/flagd/blob/main/docs/reference/fips-crypto-closure.txt), which CI regenerates and diffs, so any change to it has to be reviewed. + +## Keys and secrets + +flagd generates, wraps, stores and zeroizes no cryptographic keys. + +TLS private keys are read from operator-supplied PEM paths (`--server-cert-path`, `--server-key-path`) via `tls.LoadX509KeyPair`. +OAuth2 client credentials and static `Authorization` headers for the HTTP sync source are read from configuration or mounted files and held in memory as Go strings, which cannot be zeroized. +Protect them with file permissions, a secret manager and short credential lifetimes. diff --git a/docs/reference/fips-crypto-closure.txt b/docs/reference/fips-crypto-closure.txt new file mode 100644 index 000000000..6c2acd33e --- /dev/null +++ b/docs/reference/fips-crypto-closure.txt @@ -0,0 +1,85 @@ +crypto +crypto/aes +crypto/cipher +crypto/des +crypto/dsa +crypto/ecdh +crypto/ecdsa +crypto/ed25519 +crypto/elliptic +crypto/fips140 +crypto/hkdf +crypto/hmac +crypto/hpke +crypto/internal/boring +crypto/internal/boring/bbig +crypto/internal/boring/sig +crypto/internal/constanttime +crypto/internal/entropy +crypto/internal/fips140/v1.0.0-c2097c7c +crypto/internal/fips140/v1.0.0-c2097c7c/aes +crypto/internal/fips140/v1.0.0-c2097c7c/aes/gcm +crypto/internal/fips140/v1.0.0-c2097c7c/alias +crypto/internal/fips140/v1.0.0-c2097c7c/bigmod +crypto/internal/fips140/v1.0.0-c2097c7c/check +crypto/internal/fips140/v1.0.0-c2097c7c/drbg +crypto/internal/fips140/v1.0.0-c2097c7c/ecdh +crypto/internal/fips140/v1.0.0-c2097c7c/ecdsa +crypto/internal/fips140/v1.0.0-c2097c7c/ed25519 +crypto/internal/fips140/v1.0.0-c2097c7c/edwards25519 +crypto/internal/fips140/v1.0.0-c2097c7c/edwards25519/field +crypto/internal/fips140/v1.0.0-c2097c7c/hkdf +crypto/internal/fips140/v1.0.0-c2097c7c/hmac +crypto/internal/fips140/v1.0.0-c2097c7c/mlkem +crypto/internal/fips140/v1.0.0-c2097c7c/nistec +crypto/internal/fips140/v1.0.0-c2097c7c/nistec/fiat +crypto/internal/fips140/v1.0.0-c2097c7c/pbkdf2 +crypto/internal/fips140/v1.0.0-c2097c7c/rsa +crypto/internal/fips140/v1.0.0-c2097c7c/sha256 +crypto/internal/fips140/v1.0.0-c2097c7c/sha3 +crypto/internal/fips140/v1.0.0-c2097c7c/sha512 +crypto/internal/fips140/v1.0.0-c2097c7c/subtle +crypto/internal/fips140/v1.0.0-c2097c7c/tls12 +crypto/internal/fips140/v1.0.0-c2097c7c/tls13 +crypto/internal/fips140cache +crypto/internal/fips140deps/byteorder +crypto/internal/fips140deps/cpu +crypto/internal/fips140deps/godebug +crypto/internal/fips140hash +crypto/internal/fips140only +crypto/internal/impl +crypto/internal/rand +crypto/internal/randutil +crypto/internal/sysrand +crypto/md5 +crypto/mldsa +crypto/mlkem +crypto/pbkdf2 +crypto/rand +crypto/rc4 +crypto/rsa +crypto/sha1 +crypto/sha256 +crypto/sha3 +crypto/sha512 +crypto/subtle +crypto/tls +crypto/tls/internal/fips140tls +crypto/x509 +crypto/x509/pkix +github.com/aws/aws-sdk-go-v2/internal/v4a/internal/crypto +golang.org/x/crypto/chacha20 +golang.org/x/crypto/chacha20poly1305 +golang.org/x/crypto/cryptobyte +golang.org/x/crypto/cryptobyte/asn1 +golang.org/x/crypto/hkdf +golang.org/x/crypto/internal/alias +golang.org/x/crypto/internal/poly1305 +golang.org/x/crypto/pkcs12 +golang.org/x/crypto/pkcs12/internal/rc2 +vendor/golang.org/x/crypto/chacha20 +vendor/golang.org/x/crypto/chacha20poly1305 +vendor/golang.org/x/crypto/cryptobyte +vendor/golang.org/x/crypto/cryptobyte/asn1 +vendor/golang.org/x/crypto/internal/alias +vendor/golang.org/x/crypto/internal/poly1305 diff --git a/flagd-proxy/build.Dockerfile b/flagd-proxy/build.Dockerfile index 33941b2f8..bd32d8ea3 100644 --- a/flagd-proxy/build.Dockerfile +++ b/flagd-proxy/build.Dockerfile @@ -1,6 +1,8 @@ # Main Dockerfile for flagd builds # Build the manager binary -FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder +FROM --platform=$BUILDPLATFORM golang:1.27-alpine@sha256:4c9fe60190a2a3350ddc51de80d0224b8a6698d12bdfc999fee45ea9d6c46dbc AS builder +# The toolchain determines which FIPS snapshots exist, so pin it. +ENV GOTOOLCHAIN=local WORKDIR /src @@ -9,6 +11,9 @@ ARG TARGETARCH ARG VERSION ARG COMMIT ARG DATE +# Set to "on" for the FIPS variant, built against the CMVP-certified Go +# Cryptographic Module v1.0.0 (certificate #5247). +ARG FIPS=off # Download dependencies as a separate step to take advantage of Docker's caching. # Leverage a cache mount to /go/pkg/mod/ to speed up subsequent builds. @@ -32,11 +37,16 @@ RUN --mount=type=cache,target=/go/pkg/mod/ \ --mount=type=bind,source=./core,target=./core \ --mount=type=bind,source=./flagd,target=./flagd \ --mount=type=bind,source=./flagd-proxy,target=./flagd-proxy \ - CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -a -ldflags "-X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${DATE}" -o /bin/flagd-proxy flagd-proxy/main.go + if [ "${FIPS}" = on ]; then export GOFIPS140=v1.0.0 TAGS=fips140; else TAGS=; fi; \ + CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -a -tags "${TAGS}" -ldflags "-X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${DATE}" -o /bin/flagd-proxy flagd-proxy/main.go + +# Both tags together prove the variant built as intended: the toolchain adds +# fips140v1.0 only when GOFIPS140 is set, and fips140 is flagd's own. +RUN [ "${FIPS}" != on ] || go version -m /bin/flagd-proxy | grep -q -- '-tags=fips140,fips140v1.0' # # Use distroless as minimal base image to package the manager binary # # Refer to https://github.com/GoogleContainerTools/distroless for more details -FROM gcr.io/distroless/static:nonroot +FROM gcr.io/distroless/static:nonroot@sha256:1c2c046bc09ed40fad370b599a0b1ae7987f55b01e247cf27a7c27cd97e5bbc7 WORKDIR / COPY --from=builder /bin/flagd-proxy . USER 65532:65532 diff --git a/flagd-proxy/cmd/start.go b/flagd-proxy/cmd/start.go index d559b39df..fc6e14dc4 100644 --- a/flagd-proxy/cmd/start.go +++ b/flagd-proxy/cmd/start.go @@ -11,12 +11,14 @@ import ( "syscall" "time" + "github.com/open-feature/flagd/core/pkg/fips" "github.com/open-feature/flagd/core/pkg/logger" iService "github.com/open-feature/flagd/core/pkg/service" "github.com/open-feature/flagd/flagd-proxy/pkg/service" "github.com/open-feature/flagd/flagd-proxy/pkg/service/subscriptions" "github.com/spf13/cobra" "github.com/spf13/viper" + "go.uber.org/zap" "go.uber.org/zap/zapcore" ) @@ -67,6 +69,11 @@ var startCmd = &cobra.Command{ } logger := logger.NewLogger(l, Debug) + logger.Info("build info", zap.String("variant", fips.Variant), zap.Stringer("FIPS 140-3 mode", fips.Current())) + if err := fips.Check(); err != nil { + logger.Fatal("refusing to start", zap.Error(err)) + } + ctx, _ := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) syncStore := subscriptions.NewManager(ctx, logger) diff --git a/flagd-proxy/go.mod b/flagd-proxy/go.mod index fb9be7e28..3c6456306 100644 --- a/flagd-proxy/go.mod +++ b/flagd-proxy/go.mod @@ -1,6 +1,6 @@ module github.com/open-feature/flagd/flagd-proxy -go 1.25.5 +go 1.27 require ( buf.build/gen/go/open-feature/flagd/grpc/go v1.6.1-20260217192757-1388a552fc3c.1 diff --git a/flagd-proxy/tests/loadtest/go.mod b/flagd-proxy/tests/loadtest/go.mod index 601675227..d061fecbe 100644 --- a/flagd-proxy/tests/loadtest/go.mod +++ b/flagd-proxy/tests/loadtest/go.mod @@ -1,6 +1,6 @@ module github.com/open-feature/flagd/flagd-proxy/tests/loadtest -go 1.25.5 +go 1.27 require ( buf.build/gen/go/open-feature/flagd/grpc/go v1.6.1-20260217192757-1388a552fc3c.1 diff --git a/flagd/build.Dockerfile b/flagd/build.Dockerfile index 8a67587dc..f65832991 100644 --- a/flagd/build.Dockerfile +++ b/flagd/build.Dockerfile @@ -1,6 +1,8 @@ # Main Dockerfile for flagd builds # Build the manager binary -FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder +FROM --platform=$BUILDPLATFORM golang:1.27-alpine@sha256:4c9fe60190a2a3350ddc51de80d0224b8a6698d12bdfc999fee45ea9d6c46dbc AS builder +# The toolchain determines which FIPS snapshots exist, so pin it. +ENV GOTOOLCHAIN=local WORKDIR /src @@ -9,6 +11,9 @@ ARG TARGETARCH ARG VERSION ARG COMMIT ARG DATE +# Set to "on" for the FIPS variant, built against the CMVP-certified Go +# Cryptographic Module v1.0.0 (certificate #5247). +ARG FIPS=off # Download dependencies as a separate step to take advantage of Docker's caching. # Leverage a cache mount to /go/pkg/mod/ to speed up subsequent builds. @@ -29,11 +34,16 @@ RUN --mount=type=cache,target=/go/pkg/mod/ \ --mount=type=cache,target=/root/.cache/go-build \ --mount=type=bind,source=./core,target=./core \ --mount=type=bind,source=./flagd,target=./flagd \ - CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -a -ldflags "-X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${DATE}" -o /bin/flagd-build flagd/main.go + if [ "${FIPS}" = on ]; then export GOFIPS140=v1.0.0 TAGS=fips140; else TAGS=; fi; \ + CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -a -tags "${TAGS}" -ldflags "-X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${DATE}" -o /bin/flagd-build flagd/main.go + +# Both tags together prove the variant built as intended: the toolchain adds +# fips140v1.0 only when GOFIPS140 is set, and fips140 is flagd's own. +RUN [ "${FIPS}" != on ] || go version -m /bin/flagd-build | grep -q -- '-tags=fips140,fips140v1.0' # # Use distroless as minimal base image to package the manager binary # # Refer to https://github.com/GoogleContainerTools/distroless for more details -FROM gcr.io/distroless/static:nonroot +FROM gcr.io/distroless/static:nonroot@sha256:1c2c046bc09ed40fad370b599a0b1ae7987f55b01e247cf27a7c27cd97e5bbc7 WORKDIR / COPY --from=builder /bin/flagd-build . USER 65532:65532 diff --git a/flagd/cmd/start.go b/flagd/cmd/start.go index d60faa832..beea4f87d 100644 --- a/flagd/cmd/start.go +++ b/flagd/cmd/start.go @@ -6,6 +6,7 @@ import ( "strings" "time" + "github.com/open-feature/flagd/core/pkg/fips" "github.com/open-feature/flagd/core/pkg/logger" "github.com/open-feature/flagd/core/pkg/sync" syncbuilder "github.com/open-feature/flagd/core/pkg/sync/builder" @@ -163,7 +164,16 @@ var startCmd = &cobra.Command{ logger := logger.NewLogger(l, Debug) rtLogger := logger.WithFields(zap.String("component", "start")) - rtLogger.Info(fmt.Sprintf("flagd version: %s (%s), built at: %s", Version, Commit, Date)) + rtLogger.Info("flagd version", + zap.String("version", Version), + zap.String("commit", Commit), + zap.String("date", Date), + zap.String("variant", fips.Variant), + zap.Stringer("FIPS 140-3 mode", fips.Current()), + ) + if err := fips.Check(); err != nil { + rtLogger.Fatal("refusing to start", zap.Error(err)) + } syncProviders, err := syncbuilder.ParseSyncProviderURIs(viper.GetStringSlice(uriFlagName)) if err != nil { diff --git a/flagd/cmd/version.go b/flagd/cmd/version.go index 67abc0ceb..71a3e43d0 100644 --- a/flagd/cmd/version.go +++ b/flagd/cmd/version.go @@ -4,6 +4,7 @@ import ( "fmt" "runtime/debug" + "github.com/open-feature/flagd/core/pkg/fips" "github.com/spf13/cobra" ) @@ -28,5 +29,7 @@ var versionCmd = &cobra.Command{ } } fmt.Printf("flagd: %s (%s), built at: %s\n", Version, Commit, Date) + fmt.Printf("build variant: %s\n", fips.Variant) + fmt.Printf("FIPS 140-3 mode: %s\n", fips.Current()) }, } diff --git a/flagd/go.mod b/flagd/go.mod index 6f40cebfd..8cce7e9c1 100644 --- a/flagd/go.mod +++ b/flagd/go.mod @@ -1,6 +1,6 @@ module github.com/open-feature/flagd/flagd -go 1.25.5 +go 1.27 require ( buf.build/gen/go/open-feature/flagd/connectrpc/go v1.19.1-20260217192757-1388a552fc3c.2 diff --git a/flagd/pkg/service/flag-evaluation/connect_service.go b/flagd/pkg/service/flag-evaluation/connect_service.go index d253ddbb6..08d62f170 100644 --- a/flagd/pkg/service/flag-evaluation/connect_service.go +++ b/flagd/pkg/service/flag-evaluation/connect_service.go @@ -3,6 +3,7 @@ package service import ( "context" + "crypto/tls" "errors" "fmt" "net" @@ -191,6 +192,8 @@ func (s *ConnectService) setupServer(svcConf service.Configuration) (net.Listene ReadTimeout: 5 * time.Second, Handler: svcHandler, MaxHeaderBytes: int(svcConf.MaxRequestHeaderBytes), + // Cipher suites and curves are left unset; FIPS mode filters them. + TLSConfig: &tls.Config{MinVersion: tls.VersionTLS12}, } // Add middlewares diff --git a/flagd/pkg/service/flag-sync/sync_service_test.go b/flagd/pkg/service/flag-sync/sync_service_test.go index 694fe9fd1..09c95d5cb 100644 --- a/flagd/pkg/service/flag-sync/sync_service_test.go +++ b/flagd/pkg/service/flag-sync/sync_service_test.go @@ -189,10 +189,20 @@ func TestSyncServiceDeadlineEndToEnd(t *testing.T) { ctx, cancelFunc := context.WithCancel(context.Background()) defer cancelFunc() - _, _, err := createAndStartSyncService(port, sources, flagStore, certPath, keyPath, socketPath, ctx, tc.deadline, false) + _, doneChan, err := createAndStartSyncService(port, sources, flagStore, certPath, keyPath, socketPath, ctx, tc.deadline, false) if err != nil { t.Fatal("error creating sync service") } + // wait for the listener to actually close before returning, so the + // next subtest's bind to the same port doesn't race this shutdown. + defer func() { + cancelFunc() + select { + case <-doneChan: + case <-time.After(2 * time.Second): + t.Fatal("service did not exit within sufficient timeframe") + } + }() // when - derive a client for sync service serviceClient := getSyncClient(t, "./test-cert/ca-cert.pem", "", true, port, nil) diff --git a/flagd/profile.Dockerfile b/flagd/profile.Dockerfile index 8c9f97687..b4f934cc5 100644 --- a/flagd/profile.Dockerfile +++ b/flagd/profile.Dockerfile @@ -1,6 +1,8 @@ # Dockerfile with pprof profiler # Build the manager binary -FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder +FROM --platform=$BUILDPLATFORM golang:1.27-alpine@sha256:4c9fe60190a2a3350ddc51de80d0224b8a6698d12bdfc999fee45ea9d6c46dbc AS builder +# The toolchain determines which FIPS snapshots exist, so pin it. +ENV GOTOOLCHAIN=local WORKDIR /src @@ -9,6 +11,9 @@ ARG TARGETARCH ARG VERSION ARG COMMIT ARG DATE +# Set to "on" for the FIPS variant, built against the CMVP-certified Go +# Cryptographic Module v1.0.0 (certificate #5247). +ARG FIPS=off # Download dependencies as a separate step to take advantage of Docker's caching. # Leverage a cache mount to /go/pkg/mod/ to speed up subsequent builds. @@ -29,11 +34,16 @@ RUN --mount=type=cache,target=/go/pkg/mod/ \ --mount=type=cache,target=/root/.cache/go-build \ --mount=type=bind,source=./core,target=./core \ --mount=type=bind,source=./flagd,target=./flagd \ - CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -a -ldflags "-X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${DATE}" -o /bin/flagd-build ./flagd/main.go ./flagd/profiler.go + if [ "${FIPS}" = on ]; then export GOFIPS140=v1.0.0 TAGS=fips140; else TAGS=; fi; \ + CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -a -tags "${TAGS}" -ldflags "-X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${DATE}" -o /bin/flagd-build ./flagd/main.go ./flagd/profiler.go + +# Both tags together prove the variant built as intended: the toolchain adds +# fips140v1.0 only when GOFIPS140 is set, and fips140 is flagd's own. +RUN [ "${FIPS}" != on ] || go version -m /bin/flagd-build | grep -q -- '-tags=fips140,fips140v1.0' # Use distroless as minimal base image to package the manager binary # Refer to https://github.com/GoogleContainerTools/distroless for more details -FROM gcr.io/distroless/static:nonroot +FROM gcr.io/distroless/static:nonroot@sha256:1c2c046bc09ed40fad370b599a0b1ae7987f55b01e247cf27a7c27cd97e5bbc7 WORKDIR / COPY --from=builder /bin/flagd-build . USER 65532:65532 diff --git a/mkdocs.yml b/mkdocs.yml index 97707cbab..3db5d3d5f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -111,6 +111,7 @@ nav: - 'String Comparison': 'reference/custom-operations/string-comparison-operation.md' - 'Schema': 'reference/schema.md' - 'Monitoring': 'reference/monitoring.md' + - 'FIPS 140-3': 'reference/fips-140-3.md' - 'Specifications': - 'Providers': 'reference/specifications/providers.md' - 'Protobuf Schemas': 'reference/specifications/protos.md' diff --git a/renovate.json b/renovate.json index b2a9199a5..941300f2c 100644 --- a/renovate.json +++ b/renovate.json @@ -1,7 +1,7 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", "constraints": { - "go": "1.24" + "go": "1.26" }, "extends": ["github>open-feature/community-tooling"], "includePaths": [ diff --git a/test/integration/go.mod b/test/integration/go.mod index a155bd10c..49b5d9808 100644 --- a/test/integration/go.mod +++ b/test/integration/go.mod @@ -1,7 +1,7 @@ // this is a intentionally an "orphaned" module so that it effectively does e2e testing independently of the rest of the code module integration_test -go 1.25.8 +go 1.27 require ( github.com/go-git/go-git/v5 v5.19.2 diff --git a/test/loadtest/go.mod b/test/loadtest/go.mod index aa5aa24cb..72d252550 100644 --- a/test/loadtest/go.mod +++ b/test/loadtest/go.mod @@ -1,3 +1,5 @@ module tests.loadtest -go 1.25.5 +go 1.27 + +toolchain go1.27 diff --git a/test/zero-downtime-flagd-proxy/go.mod b/test/zero-downtime-flagd-proxy/go.mod index a87fcd8ae..15c7d5e52 100644 --- a/test/zero-downtime-flagd-proxy/go.mod +++ b/test/zero-downtime-flagd-proxy/go.mod @@ -1,6 +1,6 @@ module zero-downtime-test -go 1.25.5 +go 1.27 require ( buf.build/gen/go/open-feature/flagd/grpc/go v1.6.1-20260217192757-1388a552fc3c.1