diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 946777a..5cf37b6 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -206,17 +206,140 @@ jobs: echo "Tags:" >> $GITHUB_STEP_SUMMARY echo '${{ steps.meta.outputs.tags }}' | sed 's/^/ - /' >> $GITHUB_STEP_SUMMARY - # Build Go binaries using native runners (CGO required for SQLite) + # FIPS container image. Same source/commit/version as the standard image, + # built with --build-arg FIPS=1 (GOFIPS140=v1.0.0, -tags=duckdb_arrow,fips). + # FIPS-ness lives in the TAG (:-fips), never the version. Uses a + # single direct multi-platform buildx --push to BOTH registries (the path + # Docker Hub's Tags UI indexes; GHCR indexes it too), then cosign-signs the + # pushed manifest on each. Never tagged `latest` (the standard image owns + # latest). The FIPS binaries/bundles/SLSA provenance are produced separately + # by build-binaries; this job only adds the container option. + docker-build-fips: + name: Build & Push FIPS Image + runs-on: ubuntu-latest + needs: prepare + permissions: + contents: read + packages: write + id-token: write # required for cosign keyless signing + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v4 + with: + registry: ${{ env.REGISTRY_GHCR }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Log in to Docker Hub + uses: docker/login-action@v4 + with: + registry: ${{ env.REGISTRY_DOCKERHUB }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push FIPS image (multi-platform, direct) + id: build_fips + uses: docker/build-push-action@v7 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: | + ${{ env.REGISTRY_GHCR }}/${{ env.IMAGE_NAME }}:${{ needs.prepare.outputs.version }}-fips + ${{ env.REGISTRY_GHCR }}/${{ env.IMAGE_NAME }}:${{ needs.prepare.outputs.short_version }}-fips + ${{ env.REGISTRY_DOCKERHUB }}/${{ env.IMAGE_NAME_DOCKERHUB }}:${{ needs.prepare.outputs.version }}-fips + ${{ env.REGISTRY_DOCKERHUB }}/${{ env.IMAGE_NAME_DOCKERHUB }}:${{ needs.prepare.outputs.short_version }}-fips + cache-from: type=gha + # Separate cache scope so the FIPS layers don't evict/collide with the + # standard image's gha cache (different build-arg => different layers). + cache-to: type=gha,mode=max,scope=fips + build-args: | + VERSION=${{ needs.prepare.outputs.version }} + FIPS=1 + + - name: Install cosign + uses: sigstore/cosign-installer@v3 + + # Sign the pushed FIPS manifests on both registries by digest. The digest + # is identical across registries (same content), so one digest signs both + # repositories' references. + - name: Sign FIPS images (keyless) + env: + DIGEST: ${{ steps.build_fips.outputs.digest }} + run: | + cosign sign --yes "${{ env.REGISTRY_GHCR }}/${{ env.IMAGE_NAME }}@${DIGEST}" + cosign sign --yes "${{ env.REGISTRY_DOCKERHUB }}/${{ env.IMAGE_NAME_DOCKERHUB }}@${DIGEST}" + echo "✅ FIPS images signed (digest ${DIGEST}) on GHCR + Docker Hub" >> $GITHUB_STEP_SUMMARY + + # Smoke-test the FIPS IMAGE (not just the binary): boot the just-pushed + # GHCR image by digest (amd64 — the runner arch) and assert the running + # container reports FIPS mode active. Catches packaging defects (wrong + # binary baked, missing runtime libs) that the binary-level checks can't. + - name: Smoke test FIPS image (fips_mode active) + env: + IMAGE_REF: ${{ env.REGISTRY_GHCR }}/${{ env.IMAGE_NAME }}@${{ steps.build_fips.outputs.digest }} + run: | + docker run -d --name arc-fips-smoke --platform linux/amd64 \ + -e ARC_LOG_FORMAT=json "${IMAGE_REF}" + sleep 8 + docker logs arc-fips-smoke > fips-image-boot.log 2>&1 || true + docker rm -f arc-fips-smoke >/dev/null 2>&1 || true + echo "---- FIPS image startup log ----" + head -5 fips-image-boot.log || true + if ! grep -q '"fips_mode":true' fips-image-boot.log; then + echo "::error::FIPS image did not report fips_mode=true at startup" + exit 1 + fi + echo "✅ FIPS image booted with FIPS mode active" >> $GITHUB_STEP_SUMMARY + + # Build Go binaries using native runners (CGO required for SQLite). + # + # The matrix has two dimensions: architecture (amd64/arm64) x variant + # (standard/fips). The FIPS variant is the SAME source at the SAME version, + # compiled with -tags=fips against the CMVP-certified Go Cryptographic Module + # (GOFIPS140=v1.0.0). It is NOT a different version — the FIPS-ness lives in + # the artifact NAME (arc-fips-), never in the version string. Both + # variants reuse the same govulncheck + cosign signing machinery. build-binaries: name: Build Binaries needs: prepare strategy: + fail-fast: false matrix: include: - runner: ubuntu-latest suffix: linux-amd64 + variant: standard + artifact: arc-linux-amd64 + build_tags: duckdb_arrow + gofips: "" - runner: ubuntu-24.04-arm suffix: linux-arm64 + variant: standard + artifact: arc-linux-arm64 + build_tags: duckdb_arrow + gofips: "" + - runner: ubuntu-latest + suffix: linux-amd64 + variant: fips + artifact: arc-fips-linux-amd64 + build_tags: duckdb_arrow,fips + gofips: "v1.0.0" + - runner: ubuntu-24.04-arm + suffix: linux-arm64 + variant: fips + artifact: arc-fips-linux-arm64 + build_tags: duckdb_arrow,fips + gofips: "v1.0.0" runs-on: ${{ matrix.runner }} steps: - name: Checkout code @@ -233,22 +356,60 @@ jobs: go install golang.org/x/vuln/cmd/govulncheck@v1.1.4 govulncheck ./... + # FIPS variant only: prove the build links no non-FIPS crypto. + # Strip-independent (operates on the import graph, not symbols) so it + # works regardless of -ldflags="-s -w". + - name: Verify FIPS crypto boundary (import graph) + if: matrix.variant == 'fips' + run: | + if go list -tags=${{ matrix.build_tags }} -deps ./cmd/arc | grep -E 'golang.org/x/crypto/(bcrypt|hkdf)'; then + echo "::error::FIPS build pulls in non-approved x/crypto packages (listed above)" + exit 1 + fi + echo "✅ FIPS import-graph clean: no x/crypto/bcrypt or x/crypto/hkdf" + - name: Build binary env: CGO_ENABLED: 1 + GOFIPS140: ${{ matrix.gofips }} run: | VERSION=${{ needs.prepare.outputs.version }} - go build -tags=duckdb_arrow \ + go build -tags=${{ matrix.build_tags }} \ -ldflags "-s -w -X main.Version=${VERSION}" \ - -o arc-${{ matrix.suffix }} \ + -o ${{ matrix.artifact }} \ ./cmd/arc - chmod +x arc-${{ matrix.suffix }} + chmod +x ${{ matrix.artifact }} # Show binary info - file arc-${{ matrix.suffix }} - ls -lh arc-${{ matrix.suffix }} + file ${{ matrix.artifact }} + ls -lh ${{ matrix.artifact }} + + # FIPS variant only: boot the freshly built binary and assert it is + # actually running with the Go Cryptographic Module in FIPS mode. The + # binary fails closed if fips140 is not active (see cmd/arc startup + # guard), so a successful boot that logs "fips_mode":true is the proof. + # amd64 boots natively; arm64 runs on a native arm runner, so no + # emulation is involved on either leg. + - name: Verify FIPS mode active at runtime + if: matrix.variant == 'fips' + env: + # Pin JSON logging so the grep for the structured field is robust + # regardless of any ARC_LOG_FORMAT in the runner environment. + ARC_LOG_FORMAT: json + run: | + ./${{ matrix.artifact }} > fips-boot.log 2>&1 & + APP_PID=$! + sleep 8 + kill $APP_PID 2>/dev/null || true + echo "---- startup log ----" + head -5 fips-boot.log || true + if ! grep -q '"fips_mode":true' fips-boot.log; then + echo "::error::arc-fips binary did not report fips_mode=true at startup" + exit 1 + fi + echo "✅ arc-fips booted with FIPS mode active" - name: Install cosign uses: sigstore/cosign-installer@v3 @@ -256,21 +417,21 @@ jobs: # Sign the binary blob with keyless OIDC signing. The .bundle file # contains the signature + Rekor transparency-log entry and is shipped # as a release artifact so air-gapped/Zarf deployments can verify - # offline with: cosign verify-blob arc-linux-amd64 --bundle arc-linux-amd64.bundle + # offline with: cosign verify-blob --bundle .bundle - name: Sign binary (keyless) run: | cosign sign-blob --yes \ - arc-${{ matrix.suffix }} \ - --bundle arc-${{ matrix.suffix }}.bundle - echo "✅ Binary signed: arc-${{ matrix.suffix }}" >> $GITHUB_STEP_SUMMARY + ${{ matrix.artifact }} \ + --bundle ${{ matrix.artifact }}.bundle + echo "✅ Binary signed: ${{ matrix.artifact }}" >> $GITHUB_STEP_SUMMARY - name: Upload binary and signature bundle uses: actions/upload-artifact@v6 with: - name: arc-binary-${{ matrix.suffix }} + name: arc-binary-${{ matrix.variant }}-${{ matrix.suffix }} path: | - arc-${{ matrix.suffix }} - arc-${{ matrix.suffix }}.bundle + ${{ matrix.artifact }} + ${{ matrix.artifact }}.bundle retention-days: 7 # Aggregate binary hashes for SLSA provenance — must run after both @@ -294,14 +455,24 @@ jobs: id: hash run: | cd binaries - # Compute sha256 for each binary (exclude .bundle sidecar files) - sha256sum arc-linux-amd64 arc-linux-arm64 > sha256sums.txt + # Compute sha256 for every released binary — standard AND fips — so the + # SLSA provenance attestation covers all four artifacts. (.bundle + # signature sidecars are excluded; they are not SLSA subjects.) + sha256sum \ + arc-linux-amd64 arc-linux-arm64 \ + arc-fips-linux-amd64 arc-fips-linux-arm64 \ + > sha256sums.txt cat sha256sums.txt # SLSA generator expects base64-encoded "HASH filename\n..." lines (sha256sum format) HASHES=$(base64 -w0 sha256sums.txt) echo "hashes=${HASHES}" >> $GITHUB_OUTPUT - # Build Debian packages + # Build Debian packages (standard + FIPS variants). + # + # The FIPS package is named "arc-fips" and declares Conflicts: arc / + # Provides: arc / Replaces: arc — it installs the same /usr/bin/arc and + # arc.service, so an operator installs exactly one of the two. The binary + # inside is the arc-fips build (GOFIPS140, fips tag). debian-build: name: Build Debian Package runs-on: ubuntu-latest @@ -311,8 +482,16 @@ jobs: include: - arch: amd64 binary_suffix: linux-amd64 + variant: standard - arch: arm64 binary_suffix: linux-arm64 + variant: standard + - arch: amd64 + binary_suffix: linux-amd64 + variant: fips + - arch: arm64 + binary_suffix: linux-arm64 + variant: fips steps: - name: Checkout code uses: actions/checkout@v5 @@ -320,23 +499,34 @@ jobs: - name: Download binary uses: actions/download-artifact@v7 with: - name: arc-binary-${{ matrix.binary_suffix }} + name: arc-binary-${{ matrix.variant }}-${{ matrix.binary_suffix }} - name: Build Debian package run: | VERSION=${{ needs.prepare.outputs.version }} ARCH=${{ matrix.arch }} + VARIANT=${{ matrix.variant }} + + # Package name + the on-disk binary name differ by variant. The FIPS + # binary artifact is arc-fips-; the standard one is arc-. + if [ "$VARIANT" = "fips" ]; then + PKG_NAME="arc-fips" + SRC_BIN="arc-fips-${{ matrix.binary_suffix }}" + else + PKG_NAME="arc" + SRC_BIN="arc-${{ matrix.binary_suffix }}" + fi # Create package directory structure - PKG_DIR="arc_${VERSION}_${ARCH}" + PKG_DIR="${PKG_NAME}_${VERSION}_${ARCH}" mkdir -p ${PKG_DIR}/DEBIAN mkdir -p ${PKG_DIR}/usr/bin mkdir -p ${PKG_DIR}/etc/arc mkdir -p ${PKG_DIR}/lib/systemd/system mkdir -p ${PKG_DIR}/var/lib/arc - # Copy binary - cp arc-${{ matrix.binary_suffix }} ${PKG_DIR}/usr/bin/arc + # Copy binary (always installed as /usr/bin/arc, same service) + cp "${SRC_BIN}" ${PKG_DIR}/usr/bin/arc chmod +x ${PKG_DIR}/usr/bin/arc # Copy config @@ -365,16 +555,23 @@ jobs: WantedBy=multi-user.target SERVICE - # Create control file + # Create control file. The FIPS package conflicts with / provides / + # replaces the standard "arc" package (same binary path + service), so + # an operator installs exactly one of the two. + if [ "$VARIANT" = "fips" ]; then + DESC_SUFFIX=" (FIPS 140-3 build)" + else + DESC_SUFFIX="" + fi cat > ${PKG_DIR}/DEBIAN/control << EOF - Package: arc + Package: ${PKG_NAME} Version: ${VERSION} Section: database Priority: optional Architecture: ${ARCH} Depends: libc6 Maintainer: Basekick Labs - Description: Arc - High-Performance Time-Series Database + Description: Arc - High-Performance Time-Series Database${DESC_SUFFIX} Arc is a high-performance time-series database built on DuckDB, optimized for IoT, observability, and analytics workloads. . @@ -383,6 +580,13 @@ jobs: Homepage: https://github.com/basekick-labs/arc EOF + # For the FIPS variant, insert coexistence fields after Depends + # (single targeted insert — no blank-line surgery on the description, + # which uses the " ." paragraph separator). + if [ "$VARIANT" = "fips" ]; then + sed -i '/^Depends:/a Conflicts: arc\nProvides: arc\nReplaces: arc' ${PKG_DIR}/DEBIAN/control + fi + # Create postinst script cat > ${PKG_DIR}/DEBIAN/postinst << 'POSTINST' #!/bin/bash @@ -429,13 +633,16 @@ jobs: - name: Upload Debian package uses: actions/upload-artifact@v6 with: - name: arc-debian-${{ matrix.arch }}-${{ needs.prepare.outputs.version }} + name: arc-debian-${{ matrix.variant }}-${{ matrix.arch }}-${{ needs.prepare.outputs.version }} path: | *.deb *.deb.sha256 retention-days: 7 - # Build RPM packages + # Build RPM packages (standard + FIPS variants). + # + # The FIPS package is named "arc-fips" and declares Conflicts/Obsoletes/ + # Provides: arc — same /usr/bin/arc + arc.service, so exactly one installs. rpm-build: name: Build RPM Package runs-on: ubuntu-latest @@ -445,8 +652,16 @@ jobs: include: - arch: x86_64 binary_suffix: linux-amd64 + variant: standard + - arch: aarch64 + binary_suffix: linux-arm64 + variant: standard + - arch: x86_64 + binary_suffix: linux-amd64 + variant: fips - arch: aarch64 binary_suffix: linux-arm64 + variant: fips steps: - name: Checkout code uses: actions/checkout@v5 @@ -459,22 +674,31 @@ jobs: - name: Download binary uses: actions/download-artifact@v7 with: - name: arc-binary-${{ matrix.binary_suffix }} + name: arc-binary-${{ matrix.variant }}-${{ matrix.binary_suffix }} - name: Build RPM package run: | VERSION=${{ needs.prepare.outputs.version }} ARCH=${{ matrix.arch }} + VARIANT=${{ matrix.variant }} + + if [ "$VARIANT" = "fips" ]; then + PKG_NAME="arc-fips" + SRC_BIN="arc-fips-${{ matrix.binary_suffix }}" + else + PKG_NAME="arc" + SRC_BIN="arc-${{ matrix.binary_suffix }}" + fi # Create RPM build structure mkdir -p ~/rpmbuild/{BUILD,RPMS,SOURCES,SPECS,SRPMS} - # Create source tarball - mkdir -p arc-${VERSION} - cp arc-${{ matrix.binary_suffix }} arc-${VERSION}/arc - chmod +x arc-${VERSION}/arc - cp arc.toml arc-${VERSION}/ - tar -czf ~/rpmbuild/SOURCES/arc-${VERSION}.tar.gz arc-${VERSION} + # Create source tarball (dir is named after the package so %setup works) + mkdir -p ${PKG_NAME}-${VERSION} + cp "${SRC_BIN}" ${PKG_NAME}-${VERSION}/arc + chmod +x ${PKG_NAME}-${VERSION}/arc + cp arc.toml ${PKG_NAME}-${VERSION}/ + tar -czf ~/rpmbuild/SOURCES/${PKG_NAME}-${VERSION}.tar.gz ${PKG_NAME}-${VERSION} # Create systemd service in SOURCES cat > ~/rpmbuild/SOURCES/arc.service << 'SERVICE' @@ -497,15 +721,24 @@ jobs: WantedBy=multi-user.target SERVICE + # FIPS package coexistence directives (empty for standard). Kept on a + # single physical line each; emitted only for the FIPS variant so the + # standard spec has no extra/blank header lines. + if [ "$VARIANT" = "fips" ]; then + SUMMARY_SUFFIX=" (FIPS 140-3 build)" + else + SUMMARY_SUFFIX="" + fi + # Create spec file - cat > ~/rpmbuild/SPECS/arc.spec << EOF - Name: arc + cat > ~/rpmbuild/SPECS/${PKG_NAME}.spec << EOF + Name: ${PKG_NAME} Version: ${VERSION} Release: 1%{?dist} - Summary: High-Performance Time-Series Database + Summary: High-Performance Time-Series Database${SUMMARY_SUFFIX} License: AGPL-3.0 URL: https://github.com/basekick-labs/arc - Source0: arc-${VERSION}.tar.gz + Source0: ${PKG_NAME}-${VERSION}.tar.gz Source1: arc.service %description @@ -558,19 +791,26 @@ jobs: - Release ${VERSION} EOF + # For the FIPS variant, insert coexistence tags right after Source1 + # (a single targeted insert — avoids any blank-line surgery on the + # spec body, which has legitimate blank separators). + if [ "$VARIANT" = "fips" ]; then + sed -i '/^Source1:/a Conflicts: arc\nObsoletes: arc\nProvides: arc' ~/rpmbuild/SPECS/${PKG_NAME}.spec + fi + # Build RPM - rpmbuild -ba --target ${ARCH} ~/rpmbuild/SPECS/arc.spec + rpmbuild -ba --target ${ARCH} ~/rpmbuild/SPECS/${PKG_NAME}.spec # Copy to current directory - cp ~/rpmbuild/RPMS/${ARCH}/arc-${VERSION}-1.*.rpm . + cp ~/rpmbuild/RPMS/${ARCH}/${PKG_NAME}-${VERSION}-1.*.rpm . # Create checksum - sha256sum arc-${VERSION}-1.*.rpm > arc-${VERSION}-1.${ARCH}.rpm.sha256 + sha256sum ${PKG_NAME}-${VERSION}-1.*.rpm > ${PKG_NAME}-${VERSION}-1.${ARCH}.rpm.sha256 - name: Upload RPM package uses: actions/upload-artifact@v6 with: - name: arc-rpm-${{ matrix.arch }}-${{ needs.prepare.outputs.version }} + name: arc-rpm-${{ matrix.variant }}-${{ matrix.arch }}-${{ needs.prepare.outputs.version }} path: | *.rpm *.rpm.sha256 @@ -690,7 +930,7 @@ jobs: vuln-scan: name: Vulnerability Scan runs-on: ubuntu-latest - needs: [prepare, docker-merge] + needs: [prepare, docker-merge, docker-build-fips] permissions: contents: read packages: read @@ -733,18 +973,38 @@ jobs: path: arc-${{ needs.prepare.outputs.version }}-trivy-report.json retention-days: 7 + # Scan the FIPS image too (M2). Same OS layers as the standard image, but + # the binary differs — attach a separate JSON report so the FIPS variant + # ships its own scan evidence for a prime/auditor. + - name: Run Trivy on FIPS image (JSON — release artifact) + uses: aquasecurity/trivy-action@v0.36.0 + with: + image-ref: ${{ env.REGISTRY_GHCR }}/${{ env.IMAGE_NAME }}:${{ needs.prepare.outputs.version }}-fips + format: json + output: arc-${{ needs.prepare.outputs.version }}-fips-trivy-report.json + severity: CRITICAL,HIGH,MEDIUM + exit-code: '0' + + - name: Upload FIPS Trivy report + uses: actions/upload-artifact@v6 + with: + name: arc-fips-trivy-${{ needs.prepare.outputs.version }} + path: arc-${{ needs.prepare.outputs.version }}-fips-trivy-report.json + retention-days: 7 + - name: Summary run: | VERSION=${{ needs.prepare.outputs.version }} echo "## Vulnerability Scan" >> $GITHUB_STEP_SUMMARY echo "- Trivy report: \`arc-${VERSION}-trivy-report.json\`" >> $GITHUB_STEP_SUMMARY + echo "- FIPS Trivy report: \`arc-${VERSION}-fips-trivy-report.json\`" >> $GITHUB_STEP_SUMMARY echo "- SARIF uploaded to GitHub Security tab" >> $GITHUB_STEP_SUMMARY # Generate SBOM artifacts for supply-chain compliance (EO 14028 / SLSA) sbom: name: Generate SBOM runs-on: ubuntu-latest - needs: [prepare, docker-merge] + needs: [prepare, docker-merge, docker-build-fips] permissions: contents: read packages: read @@ -769,8 +1029,21 @@ jobs: output-file: arc-${{ needs.prepare.outputs.version }}-sbom-container.spdx.json format: spdx-json + # FIPS container SBOM (L1): same OS layers as the standard image, but the + # binary differs — ship a distinct SPDX so the FIPS image has its own + # bill of materials for a prime/auditor. + - name: Generate FIPS container SBOM (SPDX JSON) + uses: anchore/sbom-action@v0 + with: + image: ${{ env.REGISTRY_GHCR }}/${{ env.IMAGE_NAME }}:${{ needs.prepare.outputs.version }}-fips + artifact-name: arc-${{ needs.prepare.outputs.version }}-fips-sbom-container.spdx.json + output-file: arc-${{ needs.prepare.outputs.version }}-fips-sbom-container.spdx.json + format: spdx-json + # Source SBOM: Go module graph — complements the container scan by naming - # every Go dependency with its exact version and license expression. + # every Go dependency with its exact version and license expression. The + # module graph is identical for both variants (same go.mod), so one source + # SBOM covers standard and FIPS. - name: Generate source SBOM (CycloneDX JSON) uses: anchore/sbom-action@v0 with: @@ -785,6 +1058,7 @@ jobs: name: arc-sbom-${{ needs.prepare.outputs.version }} path: | arc-${{ needs.prepare.outputs.version }}-sbom-container.spdx.json + arc-${{ needs.prepare.outputs.version }}-fips-sbom-container.spdx.json arc-${{ needs.prepare.outputs.version }}-sbom-source.cyclonedx.json retention-days: 7 @@ -793,6 +1067,7 @@ jobs: VERSION=${{ needs.prepare.outputs.version }} echo "## SBOM Generated" >> $GITHUB_STEP_SUMMARY echo "- \`arc-${VERSION}-sbom-container.spdx.json\` — container image (SPDX)" >> $GITHUB_STEP_SUMMARY + echo "- \`arc-${VERSION}-fips-sbom-container.spdx.json\` — FIPS container image (SPDX)" >> $GITHUB_STEP_SUMMARY echo "- \`arc-${VERSION}-sbom-source.cyclonedx.json\` — Go module graph (CycloneDX)" >> $GITHUB_STEP_SUMMARY # Test Docker image health @@ -861,7 +1136,7 @@ jobs: - name: Download amd64 binary uses: actions/download-artifact@v7 with: - name: arc-binary-linux-amd64 + name: arc-binary-standard-linux-amd64 - name: Test binary execution run: | diff --git a/Dockerfile b/Dockerfile index 2215520..45d634d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,12 +15,25 @@ RUN go mod download && go mod verify # Copy source code COPY . . -# Build +# Build. FIPS=1 selects the arc-fips variant: same source/commit/version, +# compiled with the fips build tag against the CMVP-certified Go Cryptographic +# Module (GOFIPS140=v1.0.0) and baking in GODEBUG=fips140=only. The binary is +# always named "arc" inside the image; the FIPS-ness is conveyed by the image +# TAG (e.g. :-fips), per "FIPS is a build variant, not a version". ARG VERSION -RUN go build -v \ - -tags=duckdb_arrow \ - -ldflags="-s -w -X main.Version=${VERSION}" \ - -o arc ./cmd/arc +ARG FIPS=0 +RUN if [ "$FIPS" = "1" ]; then \ + echo "Building FIPS variant (GOFIPS140=v1.0.0, -tags=duckdb_arrow,fips)"; \ + GOFIPS140=v1.0.0 CGO_ENABLED=1 go build -v \ + -tags=duckdb_arrow,fips \ + -ldflags="-s -w -X main.Version=${VERSION}" \ + -o arc ./cmd/arc; \ + else \ + CGO_ENABLED=1 go build -v \ + -tags=duckdb_arrow \ + -ldflags="-s -w -X main.Version=${VERSION}" \ + -o arc ./cmd/arc; \ + fi # Production stage FROM debian:bookworm-slim diff --git a/Makefile b/Makefile index 3a8091e..35cbfea 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help build test run clean install deps fmt lint +.PHONY: help build build-fips test test-fips run clean install deps fmt lint fips-check # Variables BINARY_NAME=arc @@ -6,6 +6,15 @@ GO=go GOFLAGS=-v -tags=duckdb_arrow MAIN_PATH=./cmd/arc +# FIPS build variant. Same source/commit/version as the standard build — only +# the build tag and the GOFIPS140 module selection differ. GOFIPS140=v1.0.0 is +# the CMVP-certified Go Cryptographic Module snapshot (see +# $(shell go env GOROOT)/lib/fips140/certified.txt). The fips tag enables +# fail-closed legacy-token verification and bakes in GODEBUG=fips140=only. +FIPS_BINARY_NAME=arc-fips +FIPS_GOFLAGS=-v -tags=duckdb_arrow,fips +GOFIPS140_VERSION=v1.0.0 + help: ## Show this help message @echo 'Usage: make [target]' @echo '' @@ -22,12 +31,30 @@ install: ## Install dependencies (alias for deps) build: ## Build the binary $(GO) build $(GOFLAGS) -o $(BINARY_NAME) $(MAIN_PATH) +build-fips: ## Build the FIPS 140-3 variant (arc-fips) against the certified Go module + GOFIPS140=$(GOFIPS140_VERSION) CGO_ENABLED=1 $(GO) build $(FIPS_GOFLAGS) -o $(FIPS_BINARY_NAME) $(MAIN_PATH) + +fips-check: ## Verify the fips build links no non-FIPS crypto (x/crypto/bcrypt, x/crypto/hkdf) + @echo "Checking fips build import graph for non-approved crypto..." + @out=$$($(GO) list $(FIPS_GOFLAGS) -deps $(MAIN_PATH) 2>&1); \ + if [ $$? -ne 0 ]; then \ + echo "ERROR: go list failed (fips build does not compile?):"; echo "$$out"; exit 1; \ + fi; \ + if echo "$$out" | grep -E 'golang.org/x/crypto/(bcrypt|hkdf)'; then \ + echo "ERROR: fips build pulls in non-FIPS crypto above"; exit 1; \ + else \ + echo "OK: no x/crypto/bcrypt or x/crypto/hkdf in the fips build"; \ + fi + run: ## Run Arc directly (without building) $(GO) run $(GOFLAGS) $(MAIN_PATH) test: ## Run all tests $(GO) test $(GOFLAGS) -race -coverprofile=coverage.out ./... +test-fips: ## Run all tests with the fips build tag (exercises fail-closed paths) + $(GO) test $(FIPS_GOFLAGS) ./... + test-coverage: test ## Run tests with coverage report $(GO) tool cover -html=coverage.out @@ -43,6 +70,7 @@ lint: ## Run linter (requires golangci-lint) clean: ## Clean build artifacts rm -f $(BINARY_NAME) + rm -f $(FIPS_BINARY_NAME) rm -f coverage.out rm -rf ./data/arc/* diff --git a/README.md b/README.md index 2717062..4b24a4b 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,30 @@ go build -tags=duckdb_arrow ./cmd/arc ./arc ``` +### FIPS 140-3 Build + +For US defense/federal and other regulated environments, Arc ships an optional +**`arc-fips`** build: the same source at the same version, compiled against the +CMVP-certified Go Cryptographic Module and run in FIPS-only mode. Pick the +`-fips` artifact instead of the standard one. + +```bash +# Binary — download arc-fips-linux-amd64 (or -arm64) from the release +# Container — same repos, -fips tag suffix: +docker run -d -p 8000:8000 -v arc-data:/app/data ghcr.io/basekick-labs/arc:VERSION-fips +# or basekicklabs/arc:VERSION-fips + +# Build from source: +make build-fips # -> arc-fips (GOFIPS140=v1.0.0, -tags=duckdb_arrow,fips) +``` + +The FIPS build reports the same version as the standard build and logs +`"fips_mode":true` at startup. **Cutover note:** existing bcrypt-hashed API +tokens must be rotated when moving to the FIPS build (it stores new tokens with +PBKDF2 and fails bcrypt verification closed). The Go Cryptographic Module is +CMVP-certified; Arc itself is not a CMVP-listed module. See the +[FIPS 140-3 mode guide](https://docs.basekick.net/docs/configuration/fips). + --- ## Ecosystem & Integrations @@ -267,6 +291,8 @@ go build -tags=duckdb_arrow ./cmd/arc - **Data Management**: GDPR-compliant delete operations - **Observability**: Prometheus metrics, structured logging, graceful shutdown - **Reliability**: Circuit breakers, retry with exponential backoff +- **Supply chain**: SBOM (SPDX + CycloneDX), Trivy scans, cosign-signed releases, SLSA L3 provenance +- **FIPS 140-3**: Optional `arc-fips` build against the CMVP-certified Go Cryptographic Module — see [Installation](#fips-140-3-build) - **Edge Sync** (coming 26.09.1): Spoke-to-hub data transport for disconnected operations --- diff --git a/RELEASE_NOTES_2026.06.2.md b/RELEASE_NOTES_2026.06.2.md index f3629ee..e082e9b 100644 --- a/RELEASE_NOTES_2026.06.2.md +++ b/RELEASE_NOTES_2026.06.2.md @@ -59,6 +59,19 @@ The deliverable is signed scan evidence attached to each release, not "zero find - Container images on GHCR are signed by manifest digest. Verify with: `cosign verify ghcr.io/basekick-labs/arc:VERSION --certificate-identity-regexp "^https://github.com/Basekick-Labs/arc/" --certificate-oidc-issuer https://token.actions.githubusercontent.com` - `arc-VERSION.intoto.jsonl` — [SLSA Level 3](https://slsa.dev/spec/v1.0/levels) provenance attestation for the release binaries, generated by the [slsa-github-generator](https://github.com/slsa-framework/slsa-github-generator). Proves who built the artifact, from what source commit, and with what build inputs. Verify with: `slsa-verifier verify-artifact arc-linux-amd64 --provenance-path arc-VERSION.intoto.jsonl --source-uri github.com/Basekick-Labs/arc` +**FIPS 140-3 build variant (`arc-fips`).** This release adds an optional FIPS build of Arc for US defense/aerospace and other regulated environments that require validated cryptography. The FIPS variant is the **same source at the same version** as the standard build — it is not a separate product or version line. It is compiled with a `fips` build tag against the **CMVP-certified Go Cryptographic Module v1.0.0** (`GOFIPS140=v1.0.0`) and runs the module in FIPS-only mode (`GODEBUG=fips140=only` is baked into the binary). It carries the same Arrow/DuckDB performance build (`duckdb_arrow`) as the standard build. + +- **Artifacts:** `arc-fips-linux-amd64`, `arc-fips-linux-arm64` (each with a cosign `.bundle`); container images tagged `:VERSION-fips` on GHCR and Docker Hub; `arc-fips` `.deb` and `.rpm` packages (which `Conflicts`/`Provides`/`Replaces` the standard `arc` package — install one or the other; both ship `/usr/bin/arc` + `arc.service`); plus a FIPS container SBOM (`arc-VERSION-fips-sbom-container.spdx.json`) and FIPS Trivy report (`arc-VERSION-fips-trivy-report.json`). The standard `arc-linux-*` artifacts, `:VERSION` images, and `arc` packages are unchanged. SLSA provenance covers all four binaries (standard + FIPS). Both variants report the same version (e.g. `26.06.2`); the FIPS variant is identified by its artifact name and by `"fips_mode":true` in its startup log, not by a different version string. +- **What runs through the FIPS module:** TLS for the API, cluster, and MQTT paths is restricted to FIPS-approved cipher suites and curves; API-token hashing uses **PBKDF2-HMAC-SHA256** (replacing bcrypt, which is not FIPS-approved); cluster replication key derivation uses the standard-library `crypto/hkdf`. The FIPS binary links **no** non-approved crypto (verified in CI by an import-graph check) and **fails closed at startup** if it is not actually running in FIPS mode. +- **Token-hash verification is bounded against abuse:** the PBKDF2 verifier rejects an oversized or malformed hash with a cheap length check *before* decoding, caps the iteration count, and pins salt/key lengths — so a corrupted or hostile stored hash cannot drive a CPU/memory denial of service at auth time. Cluster token replication applies the same length caps on both the create and rotate paths, so a rogue node cannot persist an oversized hash into peers' state. These guards apply to **both** build variants. +- **Token rotation on cutover (required):** API tokens created by a non-FIPS build are stored as bcrypt hashes, which the FIPS build **refuses to verify** (it denies the request and logs the reason at debug level). When moving a deployment to the FIPS build, **rotate (recreate) existing API tokens** so they are re-stored as PBKDF2 hashes. New tokens are PBKDF2 automatically. +- **Crypto boundary:** all cryptography is provided by the Go Cryptographic Module. DuckDB and SQLite perform no cryptography (SQLite is used for token *storage*, not encryption) and are outside the module boundary. +- **CMVP status (read carefully):** Arc's FIPS build is compiled against the CMVP-**certified** Go Cryptographic Module v1.0.0. This means the *cryptographic module* is validated; Arc itself is **not** a CMVP-listed module, and this is **not** a claim that "Arc is FIPS 140-3 validated." Confirm the live certificate number on the [NIST CMVP list](https://csrc.nist.gov/projects/cryptographic-module-validation-program) before relying on it in a compliance submission. + +Verify the FIPS binary: `cosign verify-blob arc-fips-linux-amd64 --bundle arc-fips-linux-amd64.bundle --certificate-identity-regexp "^https://github.com/Basekick-Labs/arc/" --certificate-oidc-issuer https://token.actions.githubusercontent.com`. Verify the FIPS image: `cosign verify ghcr.io/basekick-labs/arc:VERSION-fips --certificate-identity-regexp "^https://github.com/Basekick-Labs/arc/" --certificate-oidc-issuer https://token.actions.githubusercontent.com`. + +Arc Enterprise customers get FIPS by running the `arc-fips` build with their license key — there is no separate enterprise FIPS binary. + ## Bug fixes **Pre-epoch (pre-1970) timestamps now partition into the correct hour (#312).** The ingest hour-bucketing used plain integer division (`time / microsecondsPerHour`), which truncates toward zero rather than flooring. A negative timestamp — a pre-1970 date, which Line Protocol and MessagePack both accept — was therefore filed one hour too late: a row at `1969-12-31 23:30` landed in the `1970/01/01/00/` partition instead of `1969/12/31/23/`, so a time-range query for the pre-1970 hour would miss it. Hour bucketing now floors toward negative infinity, so a timestamp always partitions into the hour that actually contains it; non-negative timestamps are unaffected (floor and truncation agree). The in-process CSV/Parquet import `partitions_created` count uses the same corrected bucketing. This fixes go-forward writes; any pre-1970 data already written by an affected build would need to be re-ingested or recompacted to move into the correct partition. diff --git a/cmd/arc/fips.go b/cmd/arc/fips.go new file mode 100644 index 0000000..2f9dd13 --- /dev/null +++ b/cmd/arc/fips.go @@ -0,0 +1,13 @@ +//go:build fips + +// This file is compiled only into the arc-fips build variant (-tags=fips). +// +// The //go:debug directive below bakes GODEBUG=fips140=only into the binary's +// default settings, so the arc-fips binary always runs the Go Cryptographic +// Module in FIPS-only mode — non-approved stdlib crypto calls fail closed, and +// an operator cannot silently downgrade the posture by omitting a GODEBUG env +// var. Pair this with building under GOFIPS140=v1.0.0 (the CMVP-certified +// module snapshot) so the compiled crypto IS the validated module source. +// +//go:debug fips140=only +package main diff --git a/cmd/arc/main.go b/cmd/arc/main.go index fd4f1ba..58cea11 100644 --- a/cmd/arc/main.go +++ b/cmd/arc/main.go @@ -28,6 +28,7 @@ import ( "github.com/basekick-labs/arc/internal/compaction" "github.com/basekick-labs/arc/internal/config" "github.com/basekick-labs/arc/internal/database" + "github.com/basekick-labs/arc/internal/fips" "github.com/basekick-labs/arc/internal/governance" "github.com/basekick-labs/arc/internal/ingest" "github.com/basekick-labs/arc/internal/license" @@ -92,7 +93,16 @@ func main() { // Setup logger logger.Setup(cfg.Log.Level, cfg.Log.Format) - log.Info().Str("version", Version).Bool("duckdb_arrow", database.ArrowEnabled).Msg("Starting Arc...") + log.Info().Str("version", Version).Bool("duckdb_arrow", database.ArrowEnabled).Bool("fips_mode", fips.Enabled()).Msg("Starting Arc...") + + // Fail closed: the arc-fips build (fips.BuildTagged) MUST run with the Go + // Cryptographic Module actually in FIPS mode. The binary bakes in + // GODEBUG=fips140=only (see cmd/arc/fips.go), but an operator could still + // override it; refuse to start rather than silently run a "FIPS" binary + // outside FIPS mode, which would defeat the posture and mislead an auditor. + if fips.BuildTagged && !fips.Enabled() { + log.Fatal().Msg("FIPS build started without the Go Cryptographic Module in FIPS mode (GODEBUG=fips140 disabled); refusing to start. Remove any GODEBUG=fips140=off override.") + } // Validate Enterprise License early (before component initialization) // This allows us to apply core limits to DuckDB and ingestion workers @@ -2660,6 +2670,7 @@ func main() { Int("port", cfg.Server.Port). Str("protocol", protocol). Str("version", Version). + Bool("fips_mode", fips.Enabled()). Msg("Arc is ready!") // Wait for shutdown signal diff --git a/internal/api/server.go b/internal/api/server.go index cd33caa..dd9286f 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -2,6 +2,7 @@ package api import ( "context" + "crypto/tls" "fmt" "net" "os" @@ -13,6 +14,7 @@ import ( "time" "github.com/basekick-labs/arc/internal/auth" + "github.com/basekick-labs/arc/internal/fips" "github.com/basekick-labs/arc/internal/logger" "github.com/basekick-labs/arc/internal/metrics" "github.com/gofiber/fiber/v2" @@ -498,7 +500,22 @@ func (s *Server) Start() error { Str("cert_file", s.tlsCert). Str("key_file", s.tlsKey). Msg("TLS enabled - starting HTTPS server") - err = s.app.ListenTLS(addr, s.tlsCert, s.tlsKey) + if fips.BuildTagged { + // FIPS build only: build our own TLS listener instead of + // Fiber's app.ListenTLS (which constructs an opaque tls.Config + // we cannot influence). fips.HardenTLSConfig pins FIPS-approved + // versions/cipher-suites/curves — the only way to control the + // public API's negotiated suites for an auditor-defensible FIPS + // posture. The default build keeps Fiber's ListenTLS unchanged + // so standard-build TLS behavior is untouched. + var ln net.Listener + ln, err = s.hardenedTLSListener(addr) + if err == nil { + err = s.app.Listener(ln) + } + } else { + err = s.app.ListenTLS(addr, s.tlsCert, s.tlsKey) + } } else { err = s.app.Listen(addr) } @@ -511,6 +528,34 @@ func (s *Server) Start() error { return nil } +// hardenedTLSListener loads the server keypair and returns a TLS net.Listener +// whose tls.Config is restricted to FIPS-approved versions, cipher suites, and +// curves (see fips.HardenTLSConfig). Used instead of Fiber's app.ListenTLS so +// Arc — not Fiber — owns the public API's TLS policy. +// +// NextProtos advertises ONLY "http/1.1" over ALPN. Fiber's engine (fasthttp) +// is HTTP/1.1-only — it does not implement HTTP/2 server-side, and Fiber's own +// ListenTLS sets no h2 ALPN either — so advertising "h2" here would let a +// client negotiate a protocol the server cannot speak. Setting "http/1.1" +// explicitly matches what a default TLS server negotiates and avoids that +// mismatch. (The h2 references elsewhere in Arc are the outbound net/http +// clients for S3/cluster, not this server.) +func (s *Server) hardenedTLSListener(addr string) (net.Listener, error) { + cert, err := tls.LoadX509KeyPair(s.tlsCert, s.tlsKey) + if err != nil { + return nil, fmt.Errorf("load API TLS keypair: %w", err) + } + tlsCfg := fips.HardenTLSConfig(&tls.Config{ + Certificates: []tls.Certificate{cert}, + NextProtos: []string{"http/1.1"}, + }) + ln, err := net.Listen("tcp", addr) + if err != nil { + return nil, fmt.Errorf("listen on %s: %w", addr, err) + } + return tls.NewListener(ln, tlsCfg), nil +} + // Shutdown gracefully shuts down the server func (s *Server) Shutdown(timeout time.Duration) error { s.logger.Info().Msg("Shutting down server gracefully...") diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 03efad5..e43603c 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -2,8 +2,10 @@ package auth import ( "context" + "crypto/pbkdf2" "crypto/rand" "crypto/sha256" + "crypto/subtle" "database/sql" "encoding/base64" "encoding/hex" @@ -11,6 +13,7 @@ import ( "fmt" "os" "path/filepath" + "strconv" "strings" "sync" "sync/atomic" @@ -18,7 +21,6 @@ import ( "github.com/basekick-labs/arc/internal/metrics" "github.com/rs/zerolog" - "golang.org/x/crypto/bcrypt" _ "github.com/mattn/go-sqlite3" ) @@ -451,26 +453,124 @@ func (am *AuthManager) cleanupExpiredCache() { } } -// hashToken generates a bcrypt hash of the token for storage +// pbkdf2Prefix marks a token hash produced by hashToken (PBKDF2-HMAC-SHA256). +// Format: $pbkdf2-sha256$$$ (base64 std, no padding +// concerns since we encode/decode with the same codec). +const pbkdf2Prefix = "$pbkdf2-sha256$" + +// pbkdf2Iterations is the PBKDF2 work factor. API tokens are 256-bit random +// values (see generateToken), so this is defense-in-depth rather than the sole +// barrier against guessing; 600k matches current OWASP guidance for +// PBKDF2-HMAC-SHA256 and keeps verify latency acceptable for the auth cache +// miss path. +const pbkdf2Iterations = 600_000 + +// pbkdf2MaxIterations bounds the iteration count accepted at verify time. A +// value above this can only come from a corrupted or hostile hash (verifying +// it would be a CPU-DoS vector — e.g. a rogue cluster node proposing a +// TokenEntry with a huge iter). The ceiling is well above pbkdf2Iterations so +// raising the minting cost later still verifies. +const pbkdf2MaxIterations = 10_000_000 + +// pbkdf2KeyLen is the derived-key length in bytes (256-bit). +const pbkdf2KeyLen = 32 + +// pbkdf2SaltLen is the per-token random salt length in bytes. +const pbkdf2SaltLen = 16 + +// pbkdf2MaxEncodedLen bounds the total length of a $pbkdf2-sha256$ encoded hash +// accepted at verify time. A well-formed hash is ~80 bytes; 128 is generous +// headroom. This is a cheap O(1) guard applied before any Atoi/base64 decode so +// an oversized hostile hash cannot force large allocations or scans. +const pbkdf2MaxEncodedLen = 128 + +// hashToken generates a PBKDF2-HMAC-SHA256 hash of the token for storage. +// +// PBKDF2 is a FIPS 140-3-approved KDF (SP 800-132) implemented in the stdlib +// crypto/pbkdf2 (Go 1.24+), which is inside the FIPS module boundary. Arc +// previously used bcrypt (golang.org/x/crypto/bcrypt), which is NOT +// FIPS-approved and sits OUTSIDE the module boundary — and crucially is NOT +// rejected by GODEBUG=fips140=only because it is not stdlib crypto. New tokens +// are therefore always PBKDF2 in BOTH build variants; verifyTokenHash handles +// verifying any pre-existing bcrypt/sha256 hashes (see verifyLegacyTokenHash, +// which fails closed in the fips build). func (am *AuthManager) hashToken(token string) (string, error) { - hash, err := bcrypt.GenerateFromPassword([]byte(token), bcrypt.DefaultCost) - if err != nil { - return "", err + salt := make([]byte, pbkdf2SaltLen) + if _, err := rand.Read(salt); err != nil { + return "", fmt.Errorf("generate token salt: %w", err) } - return string(hash), nil + dk, err := pbkdf2.Key(sha256.New, token, salt, pbkdf2Iterations, pbkdf2KeyLen) + if err != nil { + return "", fmt.Errorf("derive token hash: %w", err) + } + return fmt.Sprintf("%s%d$%s$%s", + pbkdf2Prefix, + pbkdf2Iterations, + base64.RawStdEncoding.EncodeToString(salt), + base64.RawStdEncoding.EncodeToString(dk), + ), nil } -// verifyTokenHash checks if a token matches a stored hash +// verifyTokenHash checks if a token matches a stored hash. +// +// PBKDF2 hashes (the format hashToken now emits) verify in both build +// variants. Legacy bcrypt ($2…) and sha256 hashes are delegated to +// verifyLegacyTokenHash, which is build-tag-specific: the default build +// verifies them as before (backward compatibility); the fips build fails +// closed and logs a "rotate this token" warning, because verifying a legacy +// hash would call a non-FIPS-approved algorithm (bcrypt) or a bare unsalted +// SHA-256. func (am *AuthManager) verifyTokenHash(token, hash string) bool { - // Bcrypt hash (secure, used for all new tokens since v26) - if strings.HasPrefix(hash, "$2") { - return bcrypt.CompareHashAndPassword([]byte(hash), []byte(token)) == nil + if strings.HasPrefix(hash, pbkdf2Prefix) { + return verifyPBKDF2TokenHash(token, hash) } - // SHA256 hash (legacy compatibility for pre-v26 tokens) - // New tokens always use bcrypt. This path only verifies existing old tokens. - // #nosec G401 -- Legacy compatibility only, not used for new token storage - h := sha256.Sum256([]byte(token)) - return hash == hex.EncodeToString(h[:]) + return am.verifyLegacyTokenHash(token, hash) +} + +// verifyPBKDF2TokenHash parses and verifies a $pbkdf2-sha256$ encoded hash in +// constant time. A malformed encoding returns false (fail closed). +func verifyPBKDF2TokenHash(token, hash string) bool { + // Cheap length guard FIRST, before any Atoi/base64 decode. A well-formed + // hash is ~80 bytes (10-digit iter + 24-char b64 salt + 48-char b64 key + + // separators). Rejecting oversized input up front prevents an attacker- + // supplied hash (e.g. a rogue cluster-proposed TokenEntry persisted to + // SQLite) from forcing a multi-megabyte base64 decode or Atoi scan on every + // matching auth attempt — the allocation/CPU happens BEFORE the semantic + // length checks below would reject it. + if len(hash) > pbkdf2MaxEncodedLen { + return false + } + rest := strings.TrimPrefix(hash, pbkdf2Prefix) + parts := strings.Split(rest, "$") + if len(parts) != 3 { + return false + } + // Bound the iteration count. hashToken always writes pbkdf2Iterations, so a + // value far above that can only come from a corrupted or hostile hash + // (e.g. a rogue cluster node proposing a TokenEntry with a huge iter). + // Verifying it would burn CPU — cap it to bound the work. The ceiling is + // generous (well above pbkdf2Iterations) so a future increase to the + // minting cost still verifies. + iter, err := strconv.Atoi(parts[0]) + if err != nil || iter <= 0 || iter > pbkdf2MaxIterations { + return false + } + // Pin salt and key lengths to what hashToken emits. This rejects malformed + // hashes early and prevents a hostile hash from forcing oversized + // allocations / derivations. + salt, err := base64.RawStdEncoding.DecodeString(parts[1]) + if err != nil || len(salt) != pbkdf2SaltLen { + return false + } + want, err := base64.RawStdEncoding.DecodeString(parts[2]) + if err != nil || len(want) != pbkdf2KeyLen { + return false + } + got, err := pbkdf2.Key(sha256.New, token, salt, iter, len(want)) + if err != nil { + return false + } + return subtle.ConstantTimeCompare(got, want) == 1 } // cacheKey generates a cache key for in-memory token lookup. diff --git a/internal/auth/auth_hash_fips.go b/internal/auth/auth_hash_fips.go new file mode 100644 index 0000000..f01ff6d --- /dev/null +++ b/internal/auth/auth_hash_fips.go @@ -0,0 +1,35 @@ +//go:build fips + +package auth + +import "strings" + +// verifyLegacyTokenHash fails closed in the FIPS build. +// +// Legacy token hashes are either bcrypt ($2…) — a non-FIPS-approved KDF that +// lives in golang.org/x/crypto (outside the module boundary and NOT rejected +// by GODEBUG=fips140=only) — or a bare unsalted SHA-256, which is not an +// acceptable token-hashing construction under a FIPS posture. The fips build +// therefore refuses to verify them and tells the operator to rotate the token +// (recreating it stores a PBKDF2 hash via hashToken). +// +// This file intentionally does NOT import golang.org/x/crypto/bcrypt, so the +// fips binary contains no Blowfish/bcrypt code at all — the CI symbol-absence +// check depends on this. +func (am *AuthManager) verifyLegacyTokenHash(token, hash string) bool { + kind := "sha256" + if strings.HasPrefix(hash, "$2") { + kind = "bcrypt" + } + // Logged at Debug, not Warn: this fires on every auth attempt that matches a + // legacy-hashed token's prefix. At Warn it would (a) spam logs under repeated + // auth and (b) act as a token-existence oracle for anyone watching logs — + // "token not found" is silent while "found but legacy, rejected" would log. + // Debug keeps it available for an operator actively diagnosing a failed auth + // without leaking the distinction into normal log output. + am.logger.Debug(). + Str("hash_kind", kind). + Bool("fips_mode", true). + Msg("rejecting legacy non-FIPS token hash; rotate this token (recreate it) to store a FIPS-approved PBKDF2 hash") + return false +} diff --git a/internal/auth/auth_hash_fips_test.go b/internal/auth/auth_hash_fips_test.go new file mode 100644 index 0000000..0ec65a2 --- /dev/null +++ b/internal/auth/auth_hash_fips_test.go @@ -0,0 +1,47 @@ +//go:build fips + +package auth + +import ( + "crypto/sha256" + "encoding/hex" + "testing" +) + +// In the FIPS build, legacy bcrypt and sha256 token hashes must FAIL CLOSED — +// verifying them would invoke a non-FIPS-approved algorithm. The operator is +// expected to rotate (recreate) such tokens, which stores a PBKDF2 hash. +func TestVerifyLegacyTokenHash_FIPSFailsClosed(t *testing.T) { + am, cleanup := setupTestAuthManager(t) + defer cleanup() + + t.Run("sha256 legacy hash rejected", func(t *testing.T) { + token := "legacy-token" + h := sha256.Sum256([]byte(token)) + legacy := hex.EncodeToString(h[:]) + if am.verifyTokenHash(token, legacy) { + t.Error("FIPS build must reject a legacy SHA256 token hash (fail closed)") + } + }) + + t.Run("bcrypt legacy hash rejected", func(t *testing.T) { + // A real bcrypt hash of "x"; the FIPS build must not even attempt to + // verify it (no bcrypt code is linked). Any $2 hash must be rejected. + bcryptHash := "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy" + if am.verifyTokenHash("x", bcryptHash) { + t.Error("FIPS build must reject a legacy bcrypt token hash (fail closed)") + } + }) + + // PBKDF2 hashes still verify in the FIPS build (the approved path). + t.Run("pbkdf2 still verifies", func(t *testing.T) { + token := "fips-token" + hash, err := am.hashToken(token) + if err != nil { + t.Fatalf("hashToken: %v", err) + } + if !am.verifyTokenHash(token, hash) { + t.Error("FIPS build must verify a PBKDF2 token hash") + } + }) +} diff --git a/internal/auth/auth_hash_legacy.go b/internal/auth/auth_hash_legacy.go new file mode 100644 index 0000000..b8f2f06 --- /dev/null +++ b/internal/auth/auth_hash_legacy.go @@ -0,0 +1,31 @@ +//go:build !fips + +package auth + +import ( + "crypto/sha256" + "encoding/hex" + "strings" + + "golang.org/x/crypto/bcrypt" +) + +// verifyLegacyTokenHash verifies pre-PBKDF2 token hashes in the default +// (non-FIPS) build: bcrypt ($2…) hashes minted before the PBKDF2 migration, +// and bare sha256 hashes from pre-v26 tokens. New tokens are always PBKDF2 +// (see hashToken); this path exists purely for backward compatibility so an +// upgrade does not invalidate existing tokens. +// +// The fips build replaces this file (auth_hash_fips.go) with a fail-closed +// implementation, because bcrypt is not FIPS-approved and a bare unsalted +// sha256 is not an acceptable token KDF under a FIPS posture. +func (am *AuthManager) verifyLegacyTokenHash(token, hash string) bool { + // Bcrypt hash (used for new tokens between v26 and the PBKDF2 migration). + if strings.HasPrefix(hash, "$2") { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(token)) == nil + } + // SHA256 hash (legacy compatibility for pre-v26 tokens). + // #nosec G401 -- Legacy compatibility only, not used for new token storage. + h := sha256.Sum256([]byte(token)) + return hash == hex.EncodeToString(h[:]) +} diff --git a/internal/auth/auth_hash_legacy_test.go b/internal/auth/auth_hash_legacy_test.go new file mode 100644 index 0000000..3c5265b --- /dev/null +++ b/internal/auth/auth_hash_legacy_test.go @@ -0,0 +1,34 @@ +//go:build !fips + +package auth + +import ( + "crypto/sha256" + "encoding/hex" + "testing" +) + +// In the default (non-FIPS) build, legacy bcrypt and sha256 token hashes must +// still verify so an upgrade does not invalidate existing tokens. +func TestVerifyLegacyTokenHash_DefaultBuild(t *testing.T) { + am, cleanup := setupTestAuthManager(t) + defer cleanup() + + t.Run("sha256 legacy hash verifies", func(t *testing.T) { + token := "legacy-token" + h := sha256Sum(token) + if !am.verifyTokenHash(token, h) { + t.Error("default build should verify a valid legacy SHA256 hash") + } + if am.verifyTokenHash("wrong-token", h) { + t.Error("legacy SHA256 verify should fail for wrong token") + } + }) +} + +// sha256Sum computes a SHA256 hex digest, matching the pre-v26 legacy token +// hash format. Only used by the default-build legacy verification test. +func sha256Sum(s string) string { + h := sha256.Sum256([]byte(s)) + return hex.EncodeToString(h[:]) +} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index c7d3a64..a7d9bbb 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -2,10 +2,10 @@ package auth import ( "context" - "crypto/sha256" - "encoding/hex" + "encoding/base64" "os" "path/filepath" + "strings" "testing" "time" @@ -616,41 +616,67 @@ func TestVerifyTokenHash(t *testing.T) { am, cleanup := setupTestAuthManager(t) defer cleanup() - t.Run("bcrypt hash", func(t *testing.T) { - token := "test-token-bcrypt" + t.Run("pbkdf2 round-trip", func(t *testing.T) { + token := "test-token-pbkdf2" hash, err := am.hashToken(token) if err != nil { t.Fatalf("hashToken failed: %v", err) } - + if !strings.HasPrefix(hash, pbkdf2Prefix) { + t.Errorf("hashToken should emit a %s hash, got %q", pbkdf2Prefix, hash) + } if !am.verifyTokenHash(token, hash) { - t.Error("verifyTokenHash should return true for valid bcrypt hash") + t.Error("verifyTokenHash should return true for valid PBKDF2 hash") } - if am.verifyTokenHash("wrong-token", hash) { t.Error("verifyTokenHash should return false for wrong token") } }) - t.Run("sha256 legacy hash", func(t *testing.T) { - token := "legacy-token" - // Compute SHA256 hash like legacy Python code - h := sha256Sum(token) - - if !am.verifyTokenHash(token, h) { - t.Error("verifyTokenHash should return true for valid SHA256 hash") + t.Run("malformed pbkdf2 fails closed", func(t *testing.T) { + for _, bad := range []string{ + pbkdf2Prefix, // no fields + pbkdf2Prefix + "notanint$c2FsdA$aGFzaA", // bad iter + pbkdf2Prefix + "600000$@@@$aGFzaA", // bad salt b64 + pbkdf2Prefix + "600000$c2FsdA$@@@", // bad hash b64 + pbkdf2Prefix + "0$c2FsdA$aGFzaA", // zero iter + } { + if am.verifyTokenHash("any", bad) { + t.Errorf("malformed hash %q should not verify", bad) + } } + }) - if am.verifyTokenHash("wrong-token", h) { - t.Error("verifyTokenHash should return false for wrong token") + // A hostile hash with an absurd iteration count or wrong salt/key lengths + // must fail closed BEFORE doing the expensive derivation (CPU-DoS guard, + // relevant to rogue cluster-proposed TokenEntry values). + t.Run("pbkdf2 abusive parameters fail closed", func(t *testing.T) { + token := "dos-token" + good, err := am.hashToken(token) + if err != nil { + t.Fatalf("hashToken: %v", err) + } + parts := strings.Split(strings.TrimPrefix(good, pbkdf2Prefix), "$") + // Tamper only the iteration count to far above the accepted ceiling. + abusiveIter := pbkdf2Prefix + "999999999$" + parts[1] + "$" + parts[2] + if am.verifyTokenHash(token, abusiveIter) { + t.Error("hash with iter above the ceiling must fail closed") + } + // Oversized salt (correct b64, wrong length). + bigSalt := pbkdf2Prefix + "600000$" + base64.RawStdEncoding.EncodeToString(make([]byte, 1024)) + "$" + parts[2] + if am.verifyTokenHash(token, bigSalt) { + t.Error("hash with wrong-length salt must fail closed") + } + // Multi-megabyte hash must be rejected by the upfront length guard + // WITHOUT decoding it (memory-amplification guard). + huge := pbkdf2Prefix + "600000$" + strings.Repeat("A", 8<<20) + "$" + parts[2] + if am.verifyTokenHash(token, huge) { + t.Error("oversized hash must fail closed via the length guard") } }) -} -// sha256Sum computes SHA256 hex digest (like legacy Python code) -func sha256Sum(s string) string { - h := sha256.Sum256([]byte(s)) - return hex.EncodeToString(h[:]) + // Legacy bcrypt/sha256 verification behavior is build-variant-specific and + // asserted in auth_hash_legacy_test.go (!fips) and auth_hash_fips_test.go (fips). } // TestCreateTokenWithValue tests creating a token with a user-supplied value diff --git a/internal/cluster/raft/fsm.go b/internal/cluster/raft/fsm.go index 6f1ce03..9aab160 100644 --- a/internal/cluster/raft/fsm.go +++ b/internal/cluster/raft/fsm.go @@ -227,16 +227,20 @@ type BatchFileOpsPayload struct { // This is the authoritative record of a token's existence, used by every node's // AuthManager to materialise its local SQLite cache via the FSM apply callbacks. // -// Plaintext secrecy invariant: only TokenHash (bcrypt) and TokenPrefix (SHA256 -// prefix used for the indexed lookup) are stored. The plaintext token value -// never lands in the Raft log — the proposer returns it directly to its caller -// before the command is marshalled. Same posture as today's CreateToken. +// Plaintext secrecy invariant: only TokenHash (PBKDF2-HMAC-SHA256; or a legacy +// bcrypt/sha256 hash for tokens created before the FIPS migration) and +// TokenPrefix (SHA256 prefix used for the indexed lookup) are stored. The +// plaintext token value never lands in the Raft log — the proposer returns it +// directly to its caller before the command is marshalled. Same posture as +// today's CreateToken. The hash is an opaque string here (format not +// validated), so PBKDF2 and legacy hashes replicate identically; a FIPS node +// fails legacy-hash verification closed (see auth.verifyTokenHash). type TokenEntry struct { ID int64 `json:"id"` // Raft log index at create time (deterministic across nodes) Name string `json:"name"` // Human-readable name; UNIQUE in api_tokens Description string `json:"description,omitempty"` // Optional free-text Permissions string `json:"permissions"` // Comma-separated: "read,write,delete,admin" - TokenHash string `json:"token_hash"` // bcrypt hash of the plaintext token + TokenHash string `json:"token_hash"` // PBKDF2 hash (or legacy bcrypt/sha256) of the plaintext token TokenPrefix string `json:"token_prefix"` // SHA256(token)[:16] for indexed lookup CreatedAtUnixNano int64 `json:"created_at_unix_nano"` // Proposer-set; deterministic across log replay ExpiresAtUnixNano int64 `json:"expires_at_unix_nano,omitempty"` // 0 = no expiry @@ -1407,10 +1411,43 @@ func (f *ClusterFSM) rejectToken(op string, id int64, logIndex uint64, err error // // Checks (cheapest first): // - Name non-empty and length-bounded (avoid a DOS via 1GB names). -// - TokenHash non-empty (we never accept tokens without a bcrypt hash — -// the plaintext path is proposer-side only). +// - TokenHash non-empty (we never accept tokens without a hash — +// the plaintext path is proposer-side only). Format is not checked here; +// PBKDF2 and legacy bcrypt/sha256 hashes are all opaque to this validator. // - TokenPrefix non-empty (required for the indexed lookup). // - Permissions contains only the allowed verbs. +// +// Token field length caps. Legitimate hashes are small (bcrypt 60, legacy +// sha256 64, PBKDF2 ~80); the caps are generous headroom whose purpose is to +// stop a rogue Raft proposer from persisting a multi-megabyte hash/prefix into +// every node's FSM + SQLite (which would force large allocations on each +// matching auth verify and bloat snapshots). Enforced on BOTH the create and +// rotate apply paths via validateTokenHashAndPrefix. +const ( + maxTokenHashLen = 512 + maxTokenPrefixLen = 256 +) + +// validateTokenHashAndPrefix enforces the non-empty + length-cap invariants for +// a token's hash and prefix. Shared by validateTokenEntry (create/restore) and +// applyRotateToken so the two paths cannot drift — a missing cap on rotate was +// a real gap (a rotate could replace a small hash with a 10MB one). +func validateTokenHashAndPrefix(hash, prefix string) error { + if hash == "" { + return fmt.Errorf("token hash is required") + } + if len(hash) > maxTokenHashLen { + return fmt.Errorf("token hash too long: %d > %d", len(hash), maxTokenHashLen) + } + if prefix == "" { + return fmt.Errorf("token prefix is required") + } + if len(prefix) > maxTokenPrefixLen { + return fmt.Errorf("token prefix too long: %d > %d", len(prefix), maxTokenPrefixLen) + } + return nil +} + func validateTokenEntry(entry *TokenEntry) error { if entry == nil { return fmt.Errorf("token entry is nil") @@ -1421,11 +1458,8 @@ func validateTokenEntry(entry *TokenEntry) error { if len(entry.Name) > 256 { return fmt.Errorf("token name too long: %d > 256", len(entry.Name)) } - if entry.TokenHash == "" { - return fmt.Errorf("token hash is required") - } - if entry.TokenPrefix == "" { - return fmt.Errorf("token prefix is required") + if err := validateTokenHashAndPrefix(entry.TokenHash, entry.TokenPrefix); err != nil { + return err } if err := validatePermissionString(entry.Permissions); err != nil { return err @@ -1749,8 +1783,11 @@ func (f *ClusterFSM) applyRotateToken(payload []byte, logIndex uint64) interface if p.ID == 0 { return f.rejectToken("rotate", 0, logIndex, fmt.Errorf("token id is required")) } - if p.NewHash == "" || p.NewPrefix == "" { - return f.rejectToken("rotate", p.ID, logIndex, fmt.Errorf("new_hash and new_prefix are required")) + // Same non-empty + length-cap validation the create/restore path enforces, + // so a rogue rotate command cannot replace a small hash/prefix with a + // multi-megabyte one that would bloat every node's FSM + SQLite. + if err := validateTokenHashAndPrefix(p.NewHash, p.NewPrefix); err != nil { + return f.rejectToken("rotate", p.ID, logIndex, err) } f.mu.Lock() diff --git a/internal/cluster/raft/fsm_token_test.go b/internal/cluster/raft/fsm_token_test.go index 9ac09f1..083985d 100644 --- a/internal/cluster/raft/fsm_token_test.go +++ b/internal/cluster/raft/fsm_token_test.go @@ -3,6 +3,7 @@ package raft import ( "bytes" "io" + "strings" "testing" "github.com/hashicorp/raft" @@ -116,6 +117,24 @@ func TestApplyCreateToken_RejectsMissingHash(t *testing.T) { } } +func TestApplyCreateToken_RejectsOversizedHash(t *testing.T) { + // A rogue proposer must not be able to persist a multi-megabyte TokenHash + // into every node's FSM + SQLite (it would force large allocations on each + // matching auth verify). validateTokenEntry caps the hash at 512 bytes. + fsm := newTestFSMWithBootstrapNode(t) + tok := makeTokenEntry("admin") + tok.TokenHash = strings.Repeat("A", 8<<20) // 8 MB + cmd := makeTokenCommand(t, CommandCreateToken, CreateTokenPayload{Token: tok}) + + result := fsm.Apply(&raft.Log{Data: cmd, Index: 1}) + if result == nil { + t.Fatal("oversized hash should be rejected") + } + if fsm.RejectedTokensCount() != 1 { + t.Errorf("rejection counter should bump: got %d", fsm.RejectedTokensCount()) + } +} + func TestApplyCreateToken_RejectsMissingPrefix(t *testing.T) { fsm := newTestFSMWithBootstrapNode(t) tok := makeTokenEntry("admin") @@ -436,6 +455,27 @@ func TestApplyRotateToken_RejectsMissingFields(t *testing.T) { } } +// A rogue rotate command must not be able to replace a small hash with a +// multi-megabyte one (it would bloat every node's FSM + SQLite). The rotate +// path enforces the same length caps as create (validateTokenHashAndPrefix). +func TestApplyRotateToken_RejectsOversizedHash(t *testing.T) { + fsm := newTestFSMWithBootstrapNode(t) + fsm.Apply(&raft.Log{Data: makeTokenCommand(t, CommandCreateToken, CreateTokenPayload{Token: makeTokenEntry("svc")}), Index: 1}) + + rotateCmd := makeTokenCommand(t, CommandRotateToken, RotateTokenPayload{ + ID: 1, + NewHash: strings.Repeat("A", 8<<20), // 8 MB + NewPrefix: "new-prefix", + }) + if r := fsm.Apply(&raft.Log{Data: rotateCmd, Index: 2}); r == nil { + t.Fatal("rotate with oversized new_hash should be rejected") + } + // The original (small) hash must be unchanged. + if got := fsm.GetTokenByID(1); got == nil || len(got.TokenHash) > maxTokenHashLen { + t.Errorf("oversized hash should not have been applied") + } +} + func TestFSMSnapshot_RoundTripsTokens(t *testing.T) { fsm := newTestFSMWithBootstrapNode(t) diff --git a/internal/cluster/security/auth.go b/internal/cluster/security/auth.go index f21a532..2834a0b 100644 --- a/internal/cluster/security/auth.go +++ b/internal/cluster/security/auth.go @@ -1,6 +1,7 @@ package security import ( + "crypto/hkdf" "crypto/hmac" "crypto/rand" "crypto/sha256" @@ -8,10 +9,7 @@ import ( "encoding/hex" "fmt" "hash" - "io" "time" - - "golang.org/x/crypto/hkdf" ) // MsgType is a per-message-type label bound into the join-family HMAC for @@ -319,9 +317,17 @@ func DeriveReplicationSessionKey(sharedSecret string, handshakeNonce string) ([] // HKDF-Extract uses the nonce as salt and the shared secret as IKM. // HKDF-Expand pulls 32 bytes of output keyed with the namespace // info string. - r := hkdf.New(sha256.New, []byte(sharedSecret), []byte(handshakeNonce), []byte("arc-replication-session:")) - key := make([]byte, 32) - if _, err := io.ReadFull(r, key); err != nil { + // + // Uses stdlib crypto/hkdf (Go 1.24+) rather than golang.org/x/crypto/hkdf + // so HKDF stays inside the FIPS 140-3 module boundary (the x/crypto + // version is outside it). The stdlib Key signature is + // (hash, secret, salt, info, keyLen) — secret=sharedSecret (IKM), + // salt=handshakeNonce. This ordering is byte-for-byte identical to the + // previous x/crypto New(hash, ikm, salt, info) call; a regression test + // pins the derived key (see auth_test.go) because a mismatch would + // silently split the cluster (both sides must derive the same key). + key, err := hkdf.Key(sha256.New, []byte(sharedSecret), []byte(handshakeNonce), "arc-replication-session:", 32) + if err != nil { return nil, fmt.Errorf("derive replication session key: %w", err) } return key, nil diff --git a/internal/cluster/security/auth_test.go b/internal/cluster/security/auth_test.go index b243f27..320fd2f 100644 --- a/internal/cluster/security/auth_test.go +++ b/internal/cluster/security/auth_test.go @@ -545,3 +545,51 @@ func TestValidateReplicateSyncHMAC_StaleAndFuture(t *testing.T) { t.Error("future timestamp accepted by replicate-sync validator") } } + +// TestDeriveReplicationSessionKey_KnownAnswer pins the byte output of HKDF +// session-key derivation. This guards the migration from golang.org/x/crypto/hkdf +// to stdlib crypto/hkdf (done for FIPS 140-3 boundary compliance): the stdlib +// Key signature is (hash, secret, salt, info, len) while x/crypto was +// New(hash, ikm, salt, info) — secret/IKM and salt must stay in the SAME order +// or every node derives a DIFFERENT session key and replication auth silently +// splits the cluster. The expected value was captured from the pre-migration +// x/crypto implementation; it MUST NOT change. +func TestDeriveReplicationSessionKey_KnownAnswer(t *testing.T) { + const ( + secret = "test-shared-secret-123" + nonce = "test-handshake-nonce-abc" + // Captured from golang.org/x/crypto/hkdf before the stdlib swap. + want = "f7c40933499538025701d91ccf966fd1046630b6c182ea5407058f213f1b9800" + ) + key, err := DeriveReplicationSessionKey(secret, nonce) + if err != nil { + t.Fatalf("DeriveReplicationSessionKey: %v", err) + } + if got := hex.EncodeToString(key); got != want { + t.Fatalf("HKDF session key changed across the stdlib migration:\n got=%s\nwant=%s\n(secret/salt order likely flipped — this splits the cluster)", got, want) + } +} + +// TestDeriveReplicationSessionKey_BothSidesAgree confirms both peers derive the +// same key from the same (secret, nonce) — the invariant cluster replication +// auth depends on — and that a different nonce yields a different key. +func TestDeriveReplicationSessionKey_BothSidesAgree(t *testing.T) { + a, err := DeriveReplicationSessionKey("s", "n1") + if err != nil { + t.Fatal(err) + } + b, err := DeriveReplicationSessionKey("s", "n1") + if err != nil { + t.Fatal(err) + } + if hex.EncodeToString(a) != hex.EncodeToString(b) { + t.Fatal("same inputs derived different keys — peers would not agree") + } + c, err := DeriveReplicationSessionKey("s", "n2") + if err != nil { + t.Fatal(err) + } + if hex.EncodeToString(a) == hex.EncodeToString(c) { + t.Fatal("different nonce derived same key — nonce not contributing to derivation") + } +} diff --git a/internal/cluster/security/tls.go b/internal/cluster/security/tls.go index b1cca89..58535f9 100644 --- a/internal/cluster/security/tls.go +++ b/internal/cluster/security/tls.go @@ -10,6 +10,7 @@ import ( "time" "github.com/basekick-labs/arc/internal/config" + "github.com/basekick-labs/arc/internal/fips" ) // ClusterTLSConfig creates a tls.Config from ClusterConfig. @@ -39,10 +40,14 @@ func ClusterTLSConfig(cfg *config.ClusterConfig) (*tls.Config, error) { } } - tlsCfg := &tls.Config{ + // HardenTLSConfig sets the TLS 1.2 floor in both build variants (the + // pre-existing behavior, formerly an explicit MinVersion literal here) and, + // in the fips build only, restricts cipher suites and curves to the + // FIPS-approved set. The default build keeps Go's default ciphers/curves. + // Certificates are passed in so they are preserved. + tlsCfg := fips.HardenTLSConfig(&tls.Config{ Certificates: []tls.Certificate{cert}, - MinVersion: tls.VersionTLS12, - } + }) if cfg.TLSCAFile != "" { caCert, err := os.ReadFile(cfg.TLSCAFile) diff --git a/internal/fips/build_default.go b/internal/fips/build_default.go new file mode 100644 index 0000000..4559931 --- /dev/null +++ b/internal/fips/build_default.go @@ -0,0 +1,8 @@ +//go:build !fips + +package fips + +// BuildTagged reports whether this binary was compiled with the "fips" build +// tag. False in the default (non-FIPS) build variant. See build_fips.go for +// the full contract. +const BuildTagged = false diff --git a/internal/fips/build_fips.go b/internal/fips/build_fips.go new file mode 100644 index 0000000..132f905 --- /dev/null +++ b/internal/fips/build_fips.go @@ -0,0 +1,11 @@ +//go:build fips + +package fips + +// BuildTagged reports whether this binary was compiled with the "fips" build +// tag (the arc-fips variant). This is a compile-time constant, distinct from +// Enabled() which is the runtime GODEBUG check. Code that must fail closed on +// non-approved paths (legacy token-hash verification, InsecureSkipVerify) +// keys off BuildTagged so the policy is decided at build time and cannot be +// toggled at runtime. +const BuildTagged = true diff --git a/internal/fips/fips.go b/internal/fips/fips.go new file mode 100644 index 0000000..6d10b7c --- /dev/null +++ b/internal/fips/fips.go @@ -0,0 +1,85 @@ +// Package fips centralizes Arc's FIPS 140-3 posture: detecting whether the +// process is running against the Go Cryptographic Module in FIPS mode, and +// producing TLS configurations restricted to FIPS-approved primitives. +// +// Arc ships two build variants. The default build is unchanged. The "fips" +// build (built with -tags=fips and GOFIPS140=v1.0.0) bakes in +// GODEBUG=fips140=only via a //go:debug directive (see cmd/arc/fips.go, which +// is in package main where //go:debug takes effect), runs +// against the CMVP-certified Go Cryptographic Module, and fails closed on +// non-approved code paths (legacy token hashes, InsecureSkipVerify, etc.). +// +// IMPORTANT: the Go FIPS module only covers the standard library crypto/* +// tree. golang.org/x/crypto packages (bcrypt, hkdf, …) are OUTSIDE the +// boundary and are NOT rejected by fips140=only — they must be removed by +// code change. This package exists so the policy lives in one place rather +// than being re-derived at each call site. +package fips + +import ( + "crypto/fips140" + "crypto/tls" +) + +// Enabled reports whether the process is running with the Go Cryptographic +// Module in FIPS mode (GODEBUG=fips140=on or =only). It returns true only in +// the fips build variant run with the proper GODEBUG; it is a runtime check, +// not a build-tag check. Use BuildTagged for the compile-time variant. +func Enabled() bool { + return fips140.Enabled() +} + +// approvedCipherSuites is the set of TLS 1.2 cipher suites permitted under +// FIPS 140-3 (SP 800-52r2: AES-GCM with ECDHE key exchange and ECDSA/RSA +// auth). TLS 1.3 cipher suites are not configurable in Go (the stack selects +// among AES-GCM / ChaCha20 itself, and under fips140 mode ChaCha20 is +// disabled automatically), so this list governs only the TLS 1.2 floor. +var approvedCipherSuites = []uint16{ + tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, + tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, +} + +// approvedCurves are the NIST-approved elliptic curves for FIPS mode. +// X25519 is excluded (not a FIPS-approved curve for key establishment). +var approvedCurves = []tls.CurveID{ + tls.CurveP256, + tls.CurveP384, + tls.CurveP521, +} + +// HardenTLSConfig applies Arc's TLS hardening to a config. It mutates and +// returns the passed config (or allocates one if nil) so it composes with +// builders that have already populated Certificates / RootCAs / ClientAuth +// (e.g. ClusterTLSConfig). +// +// The TLS 1.2 minimum-version floor is applied in BOTH build variants — that +// is the pre-existing behavior (every call site previously set +// MinVersion: tls.VersionTLS12 explicitly) and is plain hardening, not a +// FIPS-specific restriction. Setting it explicitly (rather than relying on +// Go's MinVersion==0 default) keeps the floor pinned across Go-version default +// changes and ignores the GODEBUG=tls10server escape hatch. +// +// The FIPS-approved cipher-suite and curve restrictions are applied ONLY in +// the fips build (BuildTagged). The default build keeps Go's default cipher +// suites and curves — notably X25519, which the FIPS-approved set excludes — +// so standard-build TLS behavior (beyond the long-standing 1.2 floor) is +// unchanged. The two build variants do not mix TLS posture. +func HardenTLSConfig(cfg *tls.Config) *tls.Config { + if cfg == nil { + cfg = &tls.Config{} + } + // TLS 1.2 floor — both build variants (pre-existing behavior). + if cfg.MinVersion < tls.VersionTLS12 { + cfg.MinVersion = tls.VersionTLS12 + } + if !BuildTagged { + // Default build: keep Go's default cipher suites / curves. + return cfg + } + // FIPS build: restrict to the FIPS-approved cipher suites and curves. + cfg.CipherSuites = approvedCipherSuites + cfg.CurvePreferences = approvedCurves + return cfg +} diff --git a/internal/fips/fips_test.go b/internal/fips/fips_test.go new file mode 100644 index 0000000..b5dfd09 --- /dev/null +++ b/internal/fips/fips_test.go @@ -0,0 +1,94 @@ +package fips + +import ( + "crypto/tls" + "testing" +) + +// TestHardenTLSConfig verifies build-variant-specific behavior: the fips build +// pins FIPS-approved version/ciphers/curves; the default build is a no-op that +// leaves Go's TLS defaults (notably X25519) untouched. The two postures must +// not mix. +func TestHardenTLSConfig(t *testing.T) { + in := &tls.Config{Certificates: []tls.Certificate{{}}} + out := HardenTLSConfig(in) + + if out != in { + t.Error("HardenTLSConfig should return the passed config") + } + if len(out.Certificates) != 1 { + t.Error("HardenTLSConfig must preserve caller-set Certificates") + } + + // The TLS 1.2 floor is applied in BOTH builds (pre-existing behavior). + if out.MinVersion != tls.VersionTLS12 { + t.Errorf("MinVersion = %x, want TLS 1.2 (%x) in all builds", out.MinVersion, tls.VersionTLS12) + } + + if !BuildTagged { + // Default build: must keep Go's default cipher suites / curves + // (including X25519). Pinning them here would silently change behavior + // for non-FIPS users. + if out.CipherSuites != nil || out.CurvePreferences != nil { + t.Errorf("default build must keep default ciphers/curves, got CipherSuites=%v CurvePreferences=%v", + out.CipherSuites, out.CurvePreferences) + } + return + } + + // fips build: must pin approved parameters. + if out.MinVersion < tls.VersionTLS12 { + t.Errorf("MinVersion = %x, want >= TLS 1.2 (%x)", out.MinVersion, tls.VersionTLS12) + } + if len(out.CipherSuites) == 0 { + t.Error("CipherSuites must be explicitly set in the fips build") + } + for _, cs := range out.CipherSuites { + if !isApprovedSuite(cs) { + t.Errorf("cipher suite %x is not in the FIPS-approved set", cs) + } + } + if len(out.CurvePreferences) == 0 { + t.Error("CurvePreferences must be explicitly set in the fips build") + } + for _, c := range out.CurvePreferences { + if c == tls.X25519 { + t.Error("X25519 is not a FIPS-approved curve and must not be present in the fips build") + } + } +} + +func TestHardenTLSConfig_NilInput(t *testing.T) { + out := HardenTLSConfig(nil) + if out == nil { + t.Fatal("HardenTLSConfig(nil) must allocate a config") + } + if out.MinVersion != tls.VersionTLS12 { + t.Errorf("nil input must still get the TLS 1.2 floor, got %x", out.MinVersion) + } +} + +// TestBuildTagImpliesFIPSEnabled documents the invariant the main() startup +// guard enforces: in the fips build variant, the binary must run with the Go +// module in FIPS mode. Under `go test -tags=fips`, GODEBUG=fips140 is not set +// for the test binary, so we only assert the build-tag constant here; the +// runtime fail-closed behavior is exercised by the release CI smoke test that +// boots the actual GOFIPS140-built binary. +func TestBuildTagImpliesFIPSEnabled(t *testing.T) { + if BuildTagged { + t.Log("fips build tag active; release CI asserts fips140.Enabled() on the GOFIPS140 binary") + } else { + if Enabled() { + t.Error("default build should not report FIPS enabled unless GOFIPS140 was set") + } + } +} + +func isApprovedSuite(cs uint16) bool { + for _, a := range approvedCipherSuites { + if a == cs { + return true + } + } + return false +} diff --git a/internal/mqtt/subscriber.go b/internal/mqtt/subscriber.go index 1848a13..9a129c6 100644 --- a/internal/mqtt/subscriber.go +++ b/internal/mqtt/subscriber.go @@ -15,6 +15,7 @@ import ( pahomqtt "github.com/eclipse/paho.mqtt.golang" "github.com/rs/zerolog" + "github.com/basekick-labs/arc/internal/fips" "github.com/basekick-labs/arc/internal/ingest" "github.com/basekick-labs/arc/internal/metrics" "github.com/basekick-labs/arc/pkg/models" @@ -217,10 +218,21 @@ func (s *Subscriber) buildClientOptions() (*pahomqtt.ClientOptions, error) { // buildTLSConfig creates TLS configuration func (s *Subscriber) buildTLSConfig() (*tls.Config, error) { - tlsConfig := &tls.Config{ - MinVersion: tls.VersionTLS12, - InsecureSkipVerify: s.config.TLSInsecureSkipVerify, - } + // In the FIPS build, refuse to disable certificate verification: skipping + // verification defeats the trust chain and is not an acceptable posture for + // a FIPS/STIG deployment (it is a configuration risk, not an algorithm one, + // so GODEBUG=fips140=only would not catch it — we enforce it here). + if fips.BuildTagged && s.config.TLSInsecureSkipVerify { + return nil, fmt.Errorf("mqtt: tls_insecure_skip_verify is not permitted in the FIPS build; provide a valid CA via tls_ca_path") + } + + // HardenTLSConfig sets the TLS 1.2 floor in both builds (pre-existing + // behavior) and, in the fips build only, pins FIPS-approved ciphers/curves. + // The fields below (InsecureSkipVerify, RootCAs, Certificates) are layered + // on after so they are preserved. + tlsConfig := fips.HardenTLSConfig(&tls.Config{ + InsecureSkipVerify: s.config.TLSInsecureSkipVerify, //nolint:gosec // guarded above in FIPS build; operator opt-in otherwise + }) // Load CA certificate if provided if s.config.TLSCAPath != "" {