diff --git a/.github/instructions/mise.instructions.md b/.github/instructions/mise.instructions.md index 0d6a1aa088..8fa0b5f396 100644 --- a/.github/instructions/mise.instructions.md +++ b/.github/instructions/mise.instructions.md @@ -126,13 +126,14 @@ Never assume `common.sh` is already loaded; always guard the source. | Library | What it provides | |---|---| -| `.mise/lib/common.sh` | Colors, `print_*`, `kms_init_env`, `require_cmd`, `has_cmd`, `has_env_vars`, `docker_ready`, `get_repo_root`, `setup_test_logging`, `run_isolated`, `wait_for_port`, `compute_sha256`, `build_test_deps`, `run_db_tests`, `setup_db_env`, `check_and_test_db`, `kms_wait_ready`, `pkcs11_check_warnings` | +| `.mise/lib/common.sh` | Colors, `print_*`, `kms_init_env`, `require_cmd`, `has_cmd`, `has_env_vars`, `docker_ready`, `get_repo_root`, `setup_test_logging`, `run_isolated`, `ensure_pnpm_v10`, `wait_for_port`, `compute_sha256`, `build_test_deps`, `run_db_tests`, `setup_db_env`, `check_and_test_db`, `kms_wait_ready`, `pkcs11_check_warnings` | | `.mise/lib/kms_build.sh` | `kms_build_server`, `kms_build_cli`, `kms_build_all`, `get_kms_bin`, `get_ckms_bin`, `get_cargo_target_dir` | | `.mise/lib/kms_server.sh` | `kms_write_config`, `kms_start`, `kms_start_from_bin`, `kms_stop`, `kms_write_ckms_conf` + globals `KMS_PID`, `KMS_URL`, `KMS_PORT` | | `.mise/lib/pkcs11_helpers.sh` | PKCS#11 slot and object helpers | | `.mise/lib/nix_helpers.sh` | Nix shell invocation, hash lookup | | `.mise/lib/package_build.sh` | Deb/RPM build helpers | | `.mise/lib/package_smoke.sh` | Smoke-test helpers for packages | +| `.mise/lib/spire_test.sh` | SPIRE suite helpers: `spire_listener_pids`, `spire_port_listening`, `spire_wait_port_closed`, `spire_stop_port`, `spire_reset_state`, `spire_ensure_certs`, `spire_build_auth_verifier` | | `.mise/lib/softhsm2.sh` | SoftHSM2 token init/teardown | | `.mise/lib/k8s.sh` | Kubernetes helpers (helm, kubectl) | | `.mise/lib/bench_helpers.sh` | Benchmark setup helpers | diff --git a/.github/workflows/main_base.yml b/.github/workflows/main_base.yml index 3efe4a7868..6203c549d9 100644 --- a/.github/workflows/main_base.yml +++ b/.github/workflows/main_base.yml @@ -31,37 +31,6 @@ jobs: with: toolchain: ${{ inputs.toolchain }} - log-reference: - name: Log index — log-reference.md in sync with source - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - with: - submodules: recursive - - - name: Check log-reference.md is up to date - id: check - run: python3 .mise/scripts/docs/update_log_index.py --check --no-color - - - name: How to fix - if: failure() - run: | - echo "" - echo "════════════════════════════════════════════════════════════" - echo " log-reference.md is out of sync with the source code." - echo "" - echo " Fix it locally by running:" - echo "" - echo " python3 .mise/scripts/docs/update_log_index.py --non-interactive --no-color" - echo "" - echo " Then review the diff, stage, and commit:" - echo "" - echo " git diff documentation/docs/configuration/log-reference.md" - echo " git add documentation/docs/configuration/log-reference.md" - echo " git commit -m 'docs: sync log-reference.md'" - echo "" - echo "════════════════════════════════════════════════════════════" - forward-proxy: uses: ./.github/workflows/forward_proxy.yml with: diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 1f35ddf6e9..3ee89f3850 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -2,8 +2,9 @@ name: Nightly Packaging # Triggered nightly (schedule), manually, on tag pushes, or on push to -# develop/main. Bot commits from nix-update-hashes.yml carry [skip ci] so -# they do NOT re-trigger this workflow. +# develop/main. Bot commits from nix-update-hashes.yml and +# update-log-index.yml carry [skip ci] so they do NOT re-trigger this +# workflow. on: push: @@ -21,7 +22,8 @@ jobs: # ───────────────────────────────────────────────────────────────────────── # Non-tag refs (branches, schedule, workflow_dispatch): # 1. Recompute Nix vendor hashes on the current branch and commit them. - # 2. Dispatch packaging.yml once both platform jobs have finished. + # 2. Sync log-reference.md with source call-sites and commit it. + # 3. Dispatch packaging.yml once both hash-update platform jobs have finished. # # Packaging is triggered by nix-update-hashes.yml (trigger_packaging=true) # so this workflow does NOT call packaging.yml directly for the branch case. @@ -34,6 +36,17 @@ jobs: trigger_packaging: true secrets: inherit + # ───────────────────────────────────────────────────────────────────────── + # Non-tag refs only: sync log-reference.md with source call-sites and + # commit the result (tag refs are already in sync via release.yml). + # ───────────────────────────────────────────────────────────────────────── + update-log-index: + if: "!startsWith(github.ref, 'refs/tags/')" + uses: ./.github/workflows/update-log-index.yml + with: + ref: ${{ github.ref_name }} + secrets: inherit + # ───────────────────────────────────────────────────────────────────────── # Tag refs only: # Nix hashes are already committed by release.yml — skip the hash update diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2898615947..e25f71173c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -236,6 +236,23 @@ jobs: secrets: PAT_TOKEN: ${{ secrets.PAT_TOKEN }} + # ───────────────────────────────────────────────────────────────────────────── + # Job 2b — Sync log-reference.md with source call-sites. + # + # Delegated to the local reusable workflow update-log-index.yml. Sequenced + # after update-nix-hashes so the two do not push to the release branch + # concurrently (both still rebase before pushing, but this avoids the race + # in the common case). + # ───────────────────────────────────────────────────────────────────────────── + update-log-index: + name: Update log index + needs: [prepare, update-nix-hashes] + uses: ./.github/workflows/update-log-index.yml + with: + ref: ${{ needs.prepare.outputs.release_branch }} + secrets: + PAT_TOKEN: ${{ secrets.PAT_TOKEN }} + # ───────────────────────────────────────────────────────────────────────────── # Job 3 — Trigger packaging CI on the release branch and wait for completion # @@ -248,7 +265,7 @@ jobs: # ───────────────────────────────────────────────────────────────────────────── trigger-packaging: name: Trigger & await packaging CI - needs: [prepare, update-nix-hashes] + needs: [prepare, update-nix-hashes, update-log-index] # nix-update-hashes.yml runs a Linux+macOS matrix internally; GitHub waits # for all instances before starting trigger-packaging. runs-on: ubuntu-latest diff --git a/.github/workflows/test_all.yml b/.github/workflows/test_all.yml index a770d42a66..a37fcf604b 100644 --- a/.github/workflows/test_all.yml +++ b/.github/workflows/test_all.yml @@ -23,7 +23,6 @@ jobs: - mariadb - psql - otel - - google-cse - redis - pykmip - wasm @@ -38,9 +37,6 @@ jobs: - iris - db2 - ase - - secret_vault - - secret_aws - - secret_azure - secret_cosmian_kms - spire - kmip-go @@ -86,13 +82,8 @@ jobs: - type: ase features: fips # secret_cosmian_kms runs against a local KMS server — works with both fips and non-fips - # secret_vault, secret_aws, secret_azure require external services — run non-fips only - - type: secret_vault - features: fips - - type: secret_aws - features: fips - - type: secret_azure - features: fips + # google-cse, secret_vault, secret_aws and secret_azure need upstream-only secrets: + # they run in the `test-nix-upstream` job, which is skipped on forks. # spire relies on the Vault API which is non-fips only - type: spire features: fips @@ -209,15 +200,75 @@ jobs: set -ex mise run test:${{ matrix.type }} --variant ${{ matrix.features }} + # Test types that depend on upstream-only secrets / external services. Kept out of + # `test-nix` because a job-level `if` cannot read the `matrix` context: the whole + # job is skipped on forks, where those secrets do not exist. + test-nix-upstream: + name: Test on ${{ matrix.type }} - ${{ matrix.features }} + runs-on: ubuntu-latest + if: github.repository == 'Cosmian/kms' + strategy: + fail-fast: false + matrix: + type: + - google-cse + - secret_vault + - secret_aws + - secret_azure + features: [fips, non-fips] + exclude: + # secret_vault, secret_aws, secret_azure require external services — run non-fips only + - type: secret_vault + features: fips + - type: secret_aws + features: fips + - type: secret_azure + features: fips + + steps: + - uses: actions/checkout@v7 + with: + submodules: recursive + + - uses: ./.github/actions/cleanup-runner + + - uses: ./.github/actions/setup-nix + + - uses: ./.github/actions/install-mise + + - name: Test + env: + # Google variables (google-cse test type) + TEST_GOOGLE_OAUTH_CLIENT_ID: ${{ secrets.TEST_GOOGLE_OAUTH_CLIENT_ID }} + TEST_GOOGLE_OAUTH_CLIENT_SECRET: ${{ secrets.TEST_GOOGLE_OAUTH_CLIENT_SECRET }} + TEST_GOOGLE_OAUTH_REFRESH_TOKEN: ${{ secrets.TEST_GOOGLE_OAUTH_REFRESH_TOKEN }} + GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY }} + + # AWS secret backend variables (secret_aws test type) + AWS_ACCESS_KEY_ID: ${{ secrets.KMS_CI_AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.KMS_CI_AWS_SECRET_ACCESS_KEY }} + AWS_REGION: ${{ secrets.KMS_CI_AWS_REGION }} + + # Azure Key Vault secret backend variables (secret_azure test type) + AZURE_TENANT_ID: ${{ secrets.KMS_CI_AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.KMS_CI_AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.KMS_CI_AZURE_CLIENT_SECRET }} + AZURE_KV_NAME: ${{ secrets.KMS_CI_AZURE_KV_NAME }} + + # Provide an authenticated token so mise can install tools from + # GitHub releases without hitting the unauthenticated rate limit. + GITHUB_TOKEN: ${{ github.token }} + run: | + set -ex + mise run test:${{ matrix.type }} --variant ${{ matrix.features }} + hsm: name: HSM ${{ matrix.hsm-type }} - ${{ matrix.features }} runs-on: ubuntu-latest - # proteccio and crypt2pay hardware do not support concurrent connections from multiple CI runs; - # give them fixed concurrency groups so only one job runs at a time across all PRs. - # utimaco and softhsm2 use a per-run group so they are never blocked by other PRs. + # Software/simulated HSMs: a per-run group so they are never blocked by other PRs. + # Hardware HSMs (proteccio, crypt2pay, aws-cloudhsm) run in the `hsm-upstream` job. concurrency: - group: ${{ (matrix.hsm-type == 'proteccio' && 'hsm-proteccio') || (matrix.hsm-type == 'crypt2pay' && 'hsm-crypt2pay') || format('hsm-{0}-{1}', matrix.hsm-type, - github.run_id) }} + group: ${{ format('hsm-{0}-{1}', matrix.hsm-type, github.run_id) }} queue: max cancel-in-progress: false strategy: @@ -225,17 +276,8 @@ jobs: matrix: hsm-type: - utimaco - - proteccio - softhsm2 - - crypt2pay features: [fips, non-fips] - exclude: - # parallel connections on proteccio is not supported - - hsm-type: proteccio - features: fips - # Not required - testing non-fips is sufficient - - hsm-type: crypt2pay - features: fips steps: - uses: actions/checkout@v7 @@ -250,18 +292,74 @@ jobs: - name: Test env: - # HSM - PROTECCIO_IP: ${{ secrets.PROTECCIO_IP }} - PROTECCIO_PASSWORD: ${{ secrets.PROTECCIO_PASSWORD }} - PROTECCIO_SLOT: ${{ secrets.PROTECCIO_SLOT }} - CRYPT2PAY_PASSWORD: ${{ secrets.CRYPT2PAY_PASSWORD }} # Google variables TEST_GOOGLE_OAUTH_CLIENT_ID: ${{ secrets.TEST_GOOGLE_OAUTH_CLIENT_ID }} TEST_GOOGLE_OAUTH_CLIENT_SECRET: ${{ secrets.TEST_GOOGLE_OAUTH_CLIENT_SECRET }} TEST_GOOGLE_OAUTH_REFRESH_TOKEN: ${{ secrets.TEST_GOOGLE_OAUTH_REFRESH_TOKEN }} GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY }} - # OVPN for Crypt2Pay HSM tests + run: | + mise run test:hsm-${{ matrix.hsm-type }} --variant ${{ matrix.features }} + + # Hardware HSMs reachable only with upstream-only secrets and infrastructure (Proteccio + # network HSM, Crypt2Pay over OpenVPN, the AWS CloudHSM CI cluster). They live in their + # own job (a job-level `if` cannot read the `matrix` context) and are skipped on forks. + hsm-upstream: + name: HSM ${{ matrix.hsm-type }} - ${{ matrix.features }} + runs-on: ubuntu-latest + if: github.repository == 'Cosmian/kms' + # This hardware does not support concurrent connections from multiple CI runs: a fixed + # group per HSM so only one job runs at a time across all PRs. + concurrency: + group: ${{ format('hsm-{0}', matrix.hsm-type) }} + queue: max + cancel-in-progress: false + strategy: + fail-fast: false + matrix: + hsm-type: + - proteccio + - crypt2pay + - aws-cloudhsm + # non-fips only: parallel connections on proteccio are not supported, non-fips is + # sufficient for crypt2pay, and aws-cloudhsm FIPS-mode compatibility is not yet + # confirmed against the cluster's supported mechanism list. + features: [non-fips] + + steps: + - uses: actions/checkout@v7 + with: + submodules: recursive + + - uses: ./.github/actions/cleanup-runner + + - uses: ./.github/actions/setup-nix + + - uses: ./.github/actions/install-mise + + - name: Test + env: + # Proteccio + PROTECCIO_IP: ${{ secrets.PROTECCIO_IP }} + PROTECCIO_PASSWORD: ${{ secrets.PROTECCIO_PASSWORD }} + PROTECCIO_SLOT: ${{ secrets.PROTECCIO_SLOT }} + # Crypt2Pay (reached through OpenVPN) + CRYPT2PAY_PASSWORD: ${{ secrets.CRYPT2PAY_PASSWORD }} OVPN_CONF: ${{ secrets.OVPN_CONF }} + # AWS CloudHSM: persistent CI cluster (see crate/hsm/aws_cloudhsm/README.md) + AWS_CLOUDHSM_CLUSTER_ID: ${{ secrets.KMS_CI_AWS_CLOUDHSM_CLUSTER_ID }} + AWS_CLOUDHSM_CU_USERNAME: ${{ secrets.KMS_CI_AWS_CLOUDHSM_CU_USERNAME }} + AWS_CLOUDHSM_CU_PASSWORD: ${{ secrets.KMS_CI_AWS_CLOUDHSM_CU_PASSWORD }} + AWS_CLOUDHSM_SLOT_ID: ${{ secrets.KMS_CI_AWS_CLOUDHSM_SLOT_ID }} + AWS_CLOUDHSM_CA_CERT: ${{ secrets.KMS_CI_AWS_CLOUDHSM_CA_CERT }} + AWS_CLOUDHSM_HSM_IPS: ${{ secrets.KMS_CI_AWS_CLOUDHSM_HSM_IPS }} + # AWS Client VPN profile (.ovpn, cert-based mutual auth): GitHub-hosted + # runners have no route to the HSM's private VPC IP; prepare_aws_cloudhsm.sh + # starts this tunnel only if the HSM is not already directly reachable. + AWS_CLOUDHSM_OVPN_CONF: ${{ secrets.KMS_CI_AWS_CLOUDHSM_OVPN_CONF }} + # Reused generic AWS credentials (read-only `cloudhsm:DescribeClusters` is enough) + AWS_ACCESS_KEY_ID: ${{ secrets.KMS_CI_AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.KMS_CI_AWS_SECRET_ACCESS_KEY }} + AWS_REGION: ${{ secrets.KMS_CI_AWS_REGION }} run: | mise run test:hsm-${{ matrix.hsm-type }} --variant ${{ matrix.features }} @@ -302,7 +400,9 @@ jobs: permissions: contents: read environment: xks-remote-approval + # Upstream only: needs the XKS test server and its secrets, which forks do not have. if: >- + github.repository == 'Cosmian/kms' && github.actor != 'dependabot[bot]' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) @@ -361,8 +461,13 @@ jobs: cargo-publish: needs: - test-nix + - test-nix-upstream - hsm + - hsm-upstream - helm + # The upstream-only jobs are skipped on forks: a skipped dependency must not skip the + # publish dry-run, but any failed or cancelled dependency still blocks it. + if: ${{ !failure() && !cancelled() }} uses: ./.github/workflows/cargo-publish.yml with: toolchain: 1.97.0 diff --git a/.github/workflows/update-log-index.yml b/.github/workflows/update-log-index.yml new file mode 100644 index 0000000000..61f1aa4bef --- /dev/null +++ b/.github/workflows/update-log-index.yml @@ -0,0 +1,81 @@ +--- +# Local reusable workflow — Update log-reference.md. +# +# Runs `mise run docs:log-index` (non-interactive, deletes stale entries) and +# commits the result back to `ref`. Mirrors nix-update-hashes.yml's +# checkout → run → commit-if-changed → rebase → push flow. +# +# Callers / triggers +# ────────────────── +# nightly.yml — non-tag refs only (branches/schedule/workflow_dispatch); tag +# refs are already in sync via release.yml. +# release.yml — Job: runs on the release branch after `prepare` (and after +# `update-nix-hashes`, to avoid two independent workflows +# pushing to the same release branch at once). + +name: Update log index + +on: + workflow_dispatch: + inputs: + ref: + description: > + Branch to update (leave empty to use the dispatched branch). + required: false + type: string + default: '' + + workflow_call: + inputs: + ref: + description: > + Git ref (branch name) to check out and push the updated + log-reference.md to. + required: true + type: string + secrets: + PAT_TOKEN: + description: > + Personal Access Token with `repo` + `workflow` scopes. + Required so that the push re-triggers other workflows + (GITHUB_TOKEN cannot do this). + required: true + +jobs: + update-log-index: + name: Update log index + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ inputs.ref || github.ref_name }} + fetch-depth: 0 + submodules: recursive + token: ${{ secrets.PAT_TOKEN }} + + - name: Configure git identity + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - uses: ./.github/actions/install-mise + + - name: Update log-reference.md + run: mise run docs:log-index + + - name: Commit updated log index + run: | + REF="${{ inputs.ref || github.ref_name }}" + git add documentation/docs/configuration/log-reference.md + if git diff --cached --quiet; then + echo "log-reference.md is already up-to-date, nothing to commit." + else + # [skip ci] prevents push-triggered workflows (e.g. nightly.yml) + # from re-running on this automated commit and creating a loop. + git commit -m "docs: sync log-reference.md [skip ci]" + # Pull after committing to avoid "index contains uncommitted + # changes" errors if another job pushed to the same ref meanwhile. + git pull --rebase origin "$REF" + git push origin "$REF" + fi diff --git a/.mise/lib/common.sh b/.mise/lib/common.sh index f30e87e18b..205d81ad70 100644 --- a/.mise/lib/common.sh +++ b/.mise/lib/common.sh @@ -175,6 +175,30 @@ require_cmd() { # Usage: has_cmd has_cmd() { command -v "$1" >/dev/null 2>&1; } +# Ensure pnpm >= 10 is available on PATH. +# +# The UI lockfile uses lockfileVersion 9.0 which requires pnpm 10+. Some +# environments (e.g. nix-shell, fresh CI runners) only provide pnpm 9.x or no +# pnpm at all, so this installs pnpm 10.17.1 from npm into a temp dir and +# prepends it to PATH when the installed pnpm is too old or missing. +# Usage: ensure_pnpm_v10 +ensure_pnpm_v10() { + local pnpm_major + pnpm_major=$(pnpm --version 2>/dev/null | cut -d. -f1 || echo "0") + if [ "${pnpm_major}" -ge 10 ]; then + return 0 + fi + if command -v npm >/dev/null 2>&1; then + local _pnpm_tmp + _pnpm_tmp="$(mktemp -d)" + npm install "pnpm@10.17.1" --prefix "${_pnpm_tmp}" --no-save --quiet >/dev/null 2>&1 || true + if [ -f "${_pnpm_tmp}/node_modules/.bin/pnpm" ]; then + export PATH="${_pnpm_tmp}/node_modules/.bin:${PATH}" + echo "Upgraded to pnpm $(pnpm --version)" + fi + fi +} + # Returns success only if every named environment variable is set and non-empty. # Usage: has_env_vars VAR_ONE VAR_TWO ... has_env_vars() { diff --git a/.mise/lib/spire_test.sh b/.mise/lib/spire_test.sh new file mode 100755 index 0000000000..fd305209ce --- /dev/null +++ b/.mise/lib/spire_test.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +# .mise/lib/spire_test.sh — Shared helpers for SPIRE integration test suites. +# +# Provides: +# spire_listener_pids — find PIDs listening on a TCP port via lsof +# spire_port_listening — test if a TCP port is currently listening +# spire_wait_port_closed — wait until a TCP port is no longer listening +# spire_stop_port — terminate any listener on a TCP port +# spire_reset_state — wipe SPIRE containers, volumes, temp DB and configs +# spire_ensure_certs — generate test TLS certificates if missing +# spire_build_auth_verifier — build auth_verifier binary and return path +# +# Requires: +# .mise/lib/common.sh (sourced automatically if needed) + +# ── Guard against double-sourcing ───────────────────────────────────────────── +[ -n "${_MISE_SPIRE_TEST_SH_LOADED:-}" ] && return 0 +_MISE_SPIRE_TEST_SH_LOADED=1 + +_SPIRE_TEST_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [ -z "${_MISE_COMMON_SH_LOADED:-}" ]; then + # shellcheck disable=SC1091 + source "${_SPIRE_TEST_LIB_DIR}/common.sh" +fi + +# Find PIDs listening on a TCP port using lsof (cross-platform macOS + Linux). +spire_listener_pids() { + local port="$1" + lsof -ti tcp:"${port}" 2>/dev/null | sort -u || true +} + +# Check if a TCP port is currently listening. +spire_port_listening() { + local port="$1" + lsof -i tcp:"${port}" -sTCP:LISTEN 2>/dev/null | grep -q . 2>/dev/null +} + +# Wait until a TCP port is closed. +spire_wait_port_closed() { + local port="$1" timeout_secs="${2:-10}" elapsed=0 + while spire_port_listening "${port}"; do + if [[ "${elapsed}" -ge "${timeout_secs}" ]]; then + return 1 + fi + sleep 1 + elapsed=$((elapsed + 1)) + done +} + +# Terminate any listener on a given port. Tries SIGTERM, then SIGKILL. +spire_stop_port() { + local port="$1" label="${2:-listener}" pids pid + pids="$(spire_listener_pids "${port}")" + pids="$(echo "${pids}" | tr -d ' ' | grep -v '^$' || true)" + [[ -z "${pids}" ]] && return 0 + + local pid_list + pid_list="$(echo "${pids}" | tr '\n' ',' | sed 's/,$//')" + print_info "Stopping stale ${label} on port ${port}: ${pid_list}" + while IFS= read -r pid; do + [[ -n "${pid}" ]] && kill "${pid}" 2>/dev/null || true + done <<<"${pids}" + + if spire_wait_port_closed "${port}" 10; then + return 0 + fi + + print_info "Port ${port} still busy after SIGTERM; forcing down..." + while IFS= read -r pid; do + [[ -n "${pid}" ]] && kill -9 "${pid}" 2>/dev/null || true + done <<<"${pids}" + + spire_wait_port_closed "${port}" 5 || + print_error "Port ${port} is still in use after stopping ${label}." +} + +# Reset Docker containers, volumes, and temporary files created by SPIRE suites. +spire_reset_state() { + local auth_db="${1:-/tmp/auth-verifier-spire.db}" + local secrets_env="${2:-/tmp/spire-secrets.env}" + + spire_stop_port 8443 "auth-verifier" + spire_stop_port 9998 "KMS" + spire_stop_port 8088 "jwks-server" + if ! docker ps >/dev/null 2>&1; then + if [[ "$(uname -s)" == "Darwin" ]]; then + open -a Docker 2>/dev/null || true + for _ in $(seq 1 30); do + docker ps >/dev/null 2>&1 && break + sleep 1 + done + fi + fi + + docker compose --profile spire down --volumes --remove-orphans 2>/dev/null || true + rm -f "${auth_db}" "${auth_db}-wal" "${auth_db}-shm" 2>/dev/null || true + rm -f "${secrets_env}" /tmp/spire-join-token.txt 2>/dev/null || true + rm -rf /tmp/spire-agent-config-* 2>/dev/null || true + rm -f /tmp/spire-server-*.log 2>/dev/null || true +} + +# Ensure TLS certificates exist in test_data/spire/certs. +spire_ensure_certs() { + local test_data="$1" + if [[ ! -f "${test_data}/certs/ca.crt" || ! -f "${test_data}/certs/jwt.key.pem" ]]; then + print_info "Generating test TLS certificates..." + bash "${test_data}/certs/generate-test-certs.sh" + fi +} + +# Build auth_verifier and echo the binary path. +# +# Callers invoke this inside `$(...)`, where `set -e` is not inherited, so every +# step reports failure explicitly: a failed build must never fall back to a stale +# `target/debug/auth_verifier`. `print_error` exits the command-substitution +# subshell with status 1, which the caller's `VAR="$(...)"` assignment propagates. +spire_build_auth_verifier() { + local repo_root="$1" + local manifest="${repo_root}/authentication/Cargo.toml" + cargo build --manifest-path "${manifest}" --bin auth_verifier >&2 || + print_error "cargo build of auth_verifier failed (${manifest})" + local target_dir bin + target_dir="$(cd "${repo_root}/authentication" && cargo metadata --no-deps --format-version 1 | + python3 -c "import sys,json; m=json.load(sys.stdin); print(m['target_directory'])")" || + print_error "cannot determine the cargo target directory of ${manifest}" + [[ -n "${target_dir}" ]] || print_error "empty cargo target directory for ${manifest}" + bin="${target_dir}/debug/auth_verifier" + [[ -x "${bin}" ]] || print_error "auth-verifier binary not found after build: ${bin}" + echo "${bin}" +} diff --git a/.mise/tasks/test/README.md b/.mise/tasks/test/README.md new file mode 100644 index 0000000000..b70c5f8ebb --- /dev/null +++ b/.mise/tasks/test/README.md @@ -0,0 +1,1776 @@ +# KMS Test Scenarios — Architecture & Sequence Diagrams + +> This document describes the component topology and runtime flow for every +> unique MISE test task under `.mise/tasks/test/`. Directory aliases +> (`db/sqlite` → `sqlite`, `hsm/softhsm2` → `hsm-softhsm2`) are **not** +> duplicated; only the canonical (implementation) task is documented. + +--- + +## Table of Contents + +1. [Master Orchestrator](#1-master-orchestrator) +2. [Database Backends](#2-database-backends) +3. [HSM Backends](#3-hsm-backends) +4. [Cloud Provider Integrations](#4-cloud-provider-integrations) +5. [Secret Backends](#5-secret-backends) +6. [SPIRE / SPIFFE](#6-spire--spiffe) +7. [PKI & Revocation](#7-pki--revocation) +8. [Audit, SIEM, Monitoring & Observability](#8-audit-siem-monitoring--observability) +9. [Database TDE Integrations](#9-database-tde-integrations) +10. [Client & Protocol Integrations](#10-client--protocol-integrations) +11. [Kubernetes E2E Tests](#11-kubernetes-e2e-tests) +12. [Web UI & WASM](#12-web-ui--wasm) +13. [Docker, Helm & Packaging](#13-docker-helm--packaging) +14. [Miscellaneous](#14-miscellaneous) + +--- + +## 1. Master Orchestrator + +### `test:_default` + +**Description:** Run every test group, auto-skipping any that lack required infra/credentials/tools. + +#### Architecture Overview + +```mermaid +graph TB + subgraph Orchestrator["test:_default"] + run["bash loop over groups"] + end + + subgraph Groups["Test Groups"] + DB["db:_default"] + HSM["hsm:_default"] + K8S["k8s:_default"] + Cloud["azure-ekm
google-cse
gcp-cmek
xks
xks-remote
secret_aws
secret_azure"] + OCSP["ocsp
pki-revocation"] + Audit["audit
monitoring
otel"] + TDE["edb-tde
docker-oracle"] + Client["jose
kmip-go
luks
openssh
pykmip
veracrypt"] + UI["ui
wasm"] + Spire["spire
spire-kmip"] + Docker["docker
iris
secret_vault"] + Local["helm
vectors-rekey
load-balancer
secret_cosmian_kms"] + end + + run --> DB + run --> HSM + run --> K8S + run --> Cloud + run --> OCSP + run --> Audit + run --> TDE + run --> Client + run --> UI + run --> Spire + run --> Docker + run --> Local +``` + +#### Sequence Diagram + +```mermaid +sequenceDiagram + participant User as User + participant Def as test:_default + participant Sub as sub-task + + User->>Def: mise run test:_default --variant fips + loop For each test group + Def->>Def: evaluate gate for the group + alt gate reports missing infra / credentials / tool + Def->>Def: SKIPPED += 1 (sub-task not run) + else gate satisfied + Def->>Sub: run sub-task + Sub-->>Def: exit 0 | non-zero + Def->>Def: PASSED += 1 or FAILED += 1 (continue) + end + end + Def-->>User: Summary: pass / fail / skip counts +``` + +--- + +## 2. Database Backends + +**Tasks:** `test:sqlite`, `test:psql`, `test:mysql`, `test:mariadb`, `test:percona`, `test:redis` +**Orchestrator:** `test:db:_default` + +All DB tests follow the same pattern: enter Nix shell (FIPS OpenSSL 3.1.2) → run `cargo test` against the KMS server and database crates with the target backend configured via environment variables. + +| Task | Backend | FIPS? | Notes | +|------|---------|-------|-------| +| `sqlite` | SQLite (file) | yes | Default. Also tests workspace binaries on CI. | +| `psql` | PostgreSQL | yes | Includes pg-failover test (docker compose stop pg1). | +| `mysql` | MySQL | yes | Disabled in CI. | +| `mariadb` | MariaDB | yes | — | +| `percona` | Percona XtraDB | yes | — | +| `redis` | Redis + Findex | **no** | Non-FIPS only. | + +### Architecture Overview + +```mermaid +graph TB + subgraph Host["Host / CI"] + Nix["Nix shell
FIPS OpenSSL 3.1.2"] + Cargo["cargo test"] + SoftHSM["SoftHSM2
(PKCS#11)"] + end + + subgraph Variants["DB Backends"] + SQLite[("SQLite
file")] + PG[("PostgreSQL
:5432")] + MySQL[("MySQL
:3306")] + MariaDB[("MariaDB
:3306")] + Percona[("Percona XtraDB
:3306")] + Redis[("Redis
+ Findex")] + end + + Nix --> Cargo + Cargo --> SQLite + Cargo --> PG + Cargo --> MySQL + Cargo --> MariaDB + Cargo --> Percona + Cargo --> Redis + SoftHSM --> Cargo +``` + +### Sequence Diagram (common pattern) + +```mermaid +sequenceDiagram + participant Script as DB test script + participant Nix as Nix shell + participant SoftHSM as softhsm2_setup + participant Cargo as cargo test + participant DB as Database + + Script->>Nix: ensure_nix_shell (WITH_HSM=1) + Script->>SoftHSM: init tokens + setenv + SoftHSM-->>Script: SOFTHSM2_CONF, HSM_SLOT_ID, etc. + Script->>Cargo: cargo test -p cosmian_kms_server + Cargo->>DB: Connect (env URL) + DB-->>Cargo: CRUD ops + crypto + Cargo->>DB: SoftHSM crypto ops + DB-->>Cargo: key material + Cargo-->>Script: test results + alt PostgreSQL only + Script->>DB: docker compose up pg-failover-1 / pg-failover-2 + Script->>Cargo: test_db_postgresql_failover (background) + Cargo->>DB: warm pool on pg1 + Script->>DB: docker stop pg1 + Cargo->>DB: failover to pg2 + DB-->>Cargo: success + end +``` + +--- + +## 3. HSM Backends + +**Tasks:** `test:hsm-softhsm2`, `test:hsm-utimaco`, `test:hsm-proteccio`, `test:hsm-crypt2pay` +**Orchestrator:** `test:hsm:_default` +**Matrix:** `test:matrix` (cross-product HSM × DB × variant) + +| Task | HSM Model | Platform | DB param | +|------|-----------|----------|----------| +| `hsm-softhsm2` | SoftHSM2 (software) | All | configurable | +| `hsm-utimaco` | Utimaco simulator | Linux only | passthrough | +| `hsm-proteccio` | Proteccio | Linux only | passthrough | +| `hsm-crypt2pay` | Crypt2Pay | Linux only | passthrough | + +### Architecture Overview + +```mermaid +graph TB + subgraph Host["Host"] + Nix2["Nix shell
FIPS OpenSSL + softhsm2"] + Cargo2["cargo test
-p cosmian_kms_server
-p test_kms_server
-p loader"] + end + + subgraph HSMs["HSM Backends"] + SHSM[("SoftHSM2
.so / .dylib")] + Utimaco[("Utimaco
Simulator")] + Proteccio[("Proteccio
HSM")] + Crypt2Pay[("Crypt2Pay
HSM")] + end + + Nix2 --> Cargo2 + Cargo2 --> SHSM + Cargo2 --> Utimaco + Cargo2 --> Proteccio + Cargo2 --> Crypt2Pay +``` + +### Sequence Diagram (SoftHSM2 example; others analogous) + +```mermaid +sequenceDiagram + participant Script as hsm-softhsm2 script + participant Nix as Nix shell + participant Setup as softhsm2_setup + participant Cargo as cargo test + participant HSM as SoftHSM2 + + Script->>Nix: ensure_nix_shell (WITH_HSM=1) + Nix-->>Script: softhsm2, openssl in PATH + Script->>Setup: init tokens / my_token + Setup-->>Script: SOFTHSM2_CONF, slot_id + Script->>Cargo: env HSM_MODEL=softhsm2 KMS_TEST_DB=... + Cargo->>HSM: PKCS#11 create key + HSM-->>Cargo: key handle + Cargo->>HSM: PKCS#11 sign / encrypt + HSM-->>Cargo: raw signature / ciphertext + Cargo-->>Script: pass / fail +``` + +--- + +## 4. Cloud Provider Integrations + +**Tasks:** `test:azure-ekm`, `test:google-cse`, `test:gcp-cmek`, `test:xks`, `test:xks-remote` + +| Task | Cloud | FIPS? | Credential requirement | +|------|-------|-------|------------------------| +| `azure-ekm` | Azure EKM | no | Nix shell; runs the plain + mTLS scripts against a local KMS | +| `google-cse` | Google CSE | yes | OAuth client + service account key | +| `gcp-cmek` | GCP CMEK wrapping | yes | Nix shell; `cargo test tests::gcp_cmek --ignored` against a local KMS | +| `xks` | AWS XKS (local) | no | KMS build + SigV4 test script | +| `xks-remote` | AWS XKS (remote) | no | Nix shell (`WITH_XKS=1`), `KMS_XKS_SIGV4_ACCESS_KEY_ID` / `KMS_XKS_SIGV4_SECRET_ACCESS_KEY`, no build | + +### Architecture Overview + +```mermaid +graph TB + subgraph Local["Local / CI"] + KMS["Cosmian KMS server
(local build)"] + TestScripts["test scripts
(curl / SigV4 / JWE)"] + CargoCloud["cargo test
(google-cse, gcp-cmek)"] + end + + subgraph Cloud["Cloud Providers"] + Azure["Azure EKM
SQL Server TDE"] + GoogleCSE["Google Workspace CSE
/Gmail Drive"] + GCPCMEK["GCP Cloud KMS
CMEK wrapping"] + AWSXKS["AWS External Key Store
XKS proxy"] + end + + KMS --> Azure + TestScripts --> Azure + KMS --> GoogleCSE + CargoCloud --> GoogleCSE + KMS --> GCPCMEK + CargoCloud --> GCPCMEK + KMS --> AWSXKS + TestScripts --> AWSXKS +``` + +### Sequence Diagram (representative — `test:xks`) + +```mermaid +sequenceDiagram + participant Script as xks test script + participant Nix as Nix shell + participant KMS as KMS server + participant Test as test_xks.sh + participant AWS as AWS XKS test harness + + Script->>Nix: ensure_nix_shell (WITH_XKS=1) + Script->>KMS: build + start + Script->>Test: bash test_xks.sh --variant non-fips + Test->>KMS: curl + AWS SigV4 GET /xks/v1/keys + KMS-->>Test: JWKS key metadata + Test->>KMS: POST /xks/v1/keys/{id}/encrypt + KMS-->>Test: ciphertext blob + Test->>KMS: POST /xks/v1/keys/{id}/decrypt + KMS-->>Test: plaintext + Test-->>Script: assertions OK +``` + +--- + +## 5. Secret Backends + +**Tasks:** `test:secret_aws`, `test:secret_azure`, `test:secret_cosmian_kms`, `test:secret_vault` + +All four tasks delegate to the same underlying test harness; only the target backend changes. + +### Architecture Overview + +```mermaid +graph TB + subgraph Host["Host / CI"] + Scripts["test_secret_*.sh"] + end + + subgraph Targets["Secret Targets"] + AWS["AWS SSM
Parameter Store"] + AzureKV["Azure Key Vault"] + Vault["HashiCorp Vault"] + CKMS["Local Cosmian KMS"] + end + + Scripts --> AWS + Scripts --> AzureKV + Scripts --> Vault + Scripts --> CKMS +``` + +### Sequence Diagram + +```mermaid +sequenceDiagram + participant Script as secret_* script + participant Env as Env check + participant Test as test_secret_*.sh + participant Backend as Secret Backend + + Script->>Env: validate required env vars + Env-->>Script: OK + Script->>Test: delegate --variant --link + Test->>Backend: connect + authenticate + Backend-->>Test: session token + Test->>Backend: store / retrieve / rotate secret + Backend-->>Test: secret value / metadata + Test-->>Script: pass / fail +``` + +--- + +## 6. SPIRE / SPIFFE + +### `test:spire` + +**Description:** SPIRE + Mistral client full integration (non-FIPS only). Multi-tenant topology with two independent SPIRE deployments against the same KMS. + +After the Mistral agent, adversarial and outage scenarios it delegates to the standalone suites: SDS delivery (step 16), `test:spire-pki` (17), `test:spire-jwt-svid` (18) and `test:spire-kmip` (19). + +#### Architecture Overview + +```mermaid +graph TB + subgraph Host["Host"] + AuthVrf["auth-verifier
:8443"] + CKMS["ckms CLI"] + Mistral["Mistral Client
(workload)"] + end + + subgraph Docker["Docker Compose"] + SSA["SPIRE Server A"] + SBA["SPIRE Server B"] + SAA["SPIRE Agent A"] + SAB["SPIRE Agent B"] + end + + subgraph KMS3["Cosmian KMS"] + KMS_S["KMS server
:9998"] + end + + AuthVrf --> |AppRole provisioning| KMS3 + SSA --> |X.509-SVID + JWT-SVID| Mistral + SBA --> |X.509-SVID + JWT-SVID| Mistral + Mistral --> |mTLS + JWT-SVID| KMS3 + CKMS --> |certify, provision| KMS3 +``` + +#### Sequence Diagram + +```mermaid +sequenceDiagram + participant Test as test:spire script + participant Auth as auth-verifier + participant KMS as KMS (bootstrap) + participant SSA as SPIRE Server A + participant SBA as SPIRE Server B + participant Mistral as Mistral Client + + Test->>Auth: start auth-verifier + Test->>KMS: start KMS, create root CA + Test->>Auth: provision AppRoles + Auth-->>Test: ROLE_ID_A, SECRET_ID_A + loop For tenant A and B + Test->>SSA: docker compose up spire-server-a + Test->>SSA: register entries, mint SVIDs + Test->>SBA: docker compose up spire-server-b + Test->>SBA: register entries, mint SVIDs + end + Test->>Mistral: start with SPIFFE SVID + Mistral->>KMS: mTLS + JWT-SVID authenticate + KMS-->>Mistral: key ops OK +``` + +--- + +### `test:spire-jwt-svid` + +**Description:** SPIFFE JWT-SVID end-to-end authentication via SPIRE + ckms CLI, with **real** JWT validation on Linux/CI. Non-FIPS only. On macOS native-tls ignores `SSL_CERT_FILE`, so the KMS cannot trust the test CA: the task then builds the KMS with the `insecure` feature, serves the JWKS over http, and skips the checks that need signature/audience validation (wrong audience, tampered signature) and the native `spire-agent` login step (Docker Desktop host networking). Those checks are covered by the Rust `real_validation` unit tests. + +On Linux the KMS is built **without** the `insecure` feature: signature, issuer, audience and expiry are all verified, and the JWKS URI must be `https`. The exported SPIRE trust bundle (x509-svid and jwt-svid keys, served unfiltered) is published by a small `python3` HTTPS server (`http.server` + `ssl`, `test_data/spire/certs/kms.crt|kms.key`, SAN `localhost`) at `https://localhost:8088/jwks.json`; only the KMS process gets `SSL_CERT_FILE=test_data/spire/certs/ca.crt`. Every temporary file (bearer-token configs, cookie jar, generated TOML, SPIRE agent state, the ckms config directory) is removed by the EXIT trap. + +#### Architecture Overview + +```mermaid +graph TB + subgraph Host["Host"] + CKMS2["ckms CLI"] + Playwright["Playwright E2E"] + JWKS["Python JWKS
HTTPS server :8088"] + Agent["SPIRE Agent
unix socket"] + AuthVrf2["auth-verifier
:8443"] + end + + subgraph Docker2["Docker"] + SPIRE_Srv["SPIRE Server A"] + end + + subgraph KMS_Host["KMS"] + K0["KMS bootstrap
:9998"] + K1["KMS jwt_svid_auth
:9998"] + K3["KMS jwt_svid_auth=false
:9998"] + K2["KMS mTLS + jwt_svid
:9998"] + end + + AuthVrf2 --> |AppRole provisioning| K0 + SPIRE_Srv --> |bundle show| JWKS + K1 --> |GET https jwks.json
trust SSL_CERT_FILE=ca.crt| JWKS + CKMS2 --> |access_token JWT-SVID| K1 + CKMS2 --> |wrong aud / bad signature: 401| K1 + CKMS2 --> |valid JWT-SVID rejected| K3 + CKMS2 --> |login spire| Agent + Agent --> |Attest and fetch| SPIRE_Srv + Playwright --> |Bearer token + /ui/login_svid session| K1 + CKMS2 --> |mTLS client cert| K2 +``` + +#### Sequence Diagram + +```mermaid +sequenceDiagram + participant CKMS as ckms CLI + participant Auth as auth-verifier + participant K0 as KMS bootstrap + participant SP as SPIRE Server A + participant JWKS as JWKS HTTPS Server + participant K1 as KMS jwt_svid + participant Agent as SPIRE Agent + participant PW as Playwright + participant K3 as KMS no jwt_svid + participant K2 as KMS dual auth + + CKMS->>CKMS: build KMS (no insecure feature), ckms, auth-verifier + CKMS->>Auth: start auth-verifier on :8443 + CKMS->>K0: start KMS bootstrap + CKMS->>K0: certify vault_pki_ca_cert + CKMS->>Auth: provision AppRoles + Auth-->>CKMS: ROLE_ID_A, SECRET_ID_A + CKMS->>SP: docker compose up spire-server-a + SP-->>CKMS: bundle show (SPIFFE JWKS) + CKMS->>JWKS: python3 HTTPS server :8088 (test CA cert) + CKMS->>K0: stop bootstrap KMS + CKMS->>K1: start KMS, jwt_svid_auth=true, provider without audience + K1-->>CKMS: exits non-zero, log mentions the missing audience + CKMS->>K1: start KMS with idp_auth jwt_svid_auth=true and audience + K1->>JWKS: fetch JWKS over HTTPS (SSL_CERT_FILE=ca.crt) + CKMS->>SP: spire-server jwt mint + SP-->>CKMS: JWT-SVID token + CKMS->>K1: sym keys create access_token=JWT + K1-->>CKMS: key created, owned by SPIFFE ID + CKMS->>SP: mint SVID for another audience + CKMS->>K1: bearer + POST /ui/login_svid with wrong-audience SVID + K1-->>CKMS: ckms fails, HTTP 401 + CKMS->>K1: bearer + POST /ui/login_svid with tampered signature + K1-->>CKMS: ckms fails, HTTP 401 + CKMS->>Agent: spire-agent start (join token, workload entry) + CKMS->>Agent: ckms login spire (must succeed if spire-agent is installed) + Agent->>SP: fetch JWT-SVID via Workload API + Agent-->>CKMS: token stored in ckms config + PW->>K1: E2E with TEST_JWT_SVID_TOKEN (spiffe-jwt-svid-auth.spec.ts) + PW->>K1: session via POST /ui/login_svid then SPA (spiffe-ui-login.spec.ts, only with a real ui/dist) + CKMS->>SP: mint demo-user JWT-SVID + SP-->>CKMS: demo JWT token + CKMS->>K1: POST /ui/login_svid + GET /ui/whoami + K1-->>CKMS: Authenticated + SPIFFE ID + CKMS->>K1: stop KMS + CKMS->>K3: start KMS with jwt_svid_auth=false + CKMS->>K3: valid JWT-SVID as bearer / POST /ui/login_svid + K3-->>CKMS: ckms fails, bearer HTTP 401, login_svid not 200 + CKMS->>K3: stop KMS + CKMS->>K2: start KMS with mTLS + jwt_svid_auth + CKMS->>K2: sym keys create via JWT-SVID + CKMS->>K2: sym keys create via mTLS cert +``` + +--- + +### `test:spire-kmip-key-manager` + +**Description:** SPIRE kmip KeyManager plugin — binary KMIP 2.1 TCP/TLS. + +#### Architecture Overview + +```mermaid +graph TB + subgraph Host3["Host"] + Go3["Go toolchain"] + OpenSSL3["openssl"] + end + + subgraph SPIRE_Build3["SPIRE build"] + SPIRE_Fork3["Cosmian/spire fork
eviden-kms-plugins"] + SPIRE_Bin3["spire-server binary"] + end + + subgraph KMS4["KMS"] + HTTPS3["HTTPS API :9998"] + KMIP_Socket["Binary KMIP TCP
socket_server :5696"] + end + + subgraph Certs3["mTLS certificates"] + CA3["Test CA P-384"] + KMS_Cert3["KMS server cert"] + SP_Cert3["SPIRE client cert"] + end + + SPIRE_Fork3 --> |go build| SPIRE_Bin3 + SPIRE_Bin3 --> |KeyManager plugin| KMIP_Socket + SPIRE_Bin3 --> |mTLS| Certs3 + KMIP_Socket --> |mTLS| Certs3 +``` + +#### Sequence Diagram + +```mermaid +sequenceDiagram + participant Test as test script + participant Cargo as cargo build + participant Go as go build + participant OpenSSL as openssl + participant KMS as KMS socket_server + HTTPS + participant SP as SPIRE Server kmip KeyManager + + Test->>Cargo: build KMS server non-fips + Test->>Cargo: build ckms CLI + Test->>Go: clone Cosmian/spire fork + Test->>Go: go build spire-server + Test->>OpenSSL: generate CA + KMS cert + SPIRE client cert + Test->>KMS: write kms.toml HTTPS :9998 socket_server :5696 mTLS + Test->>KMS: start KMS + KMS-->>Test: HTTPS ready, KMIP TCP ready + Test->>SP: write server.conf KeyManager kmip mTLS + Test->>SP: spire-server run -config server.conf + SP->>KMS: KMIP Create + Get KeyManager ops + KMS-->>SP: key material via binary TTLV + Test->>SP: spire-server healthcheck + SP-->>Test: healthy + Test->>SP: spire-server token generate + SP-->>Test: join token proves KeyManager created signing key + Test->>SP: grep ERROR/FATAL in spire.log + SP-->>Test: no errors +``` + +--- + +### `test:spire-kmip-upstream-authority` + +**Description:** SPIRE kmip UpstreamAuthority plugin — KMS-backed CA signing. + +#### Architecture Overview + +```mermaid +graph TB + subgraph Host4["Host"] + Go4["Go toolchain"] + OpenSSL4["openssl"] + CKMS3["ckms CLI"] + end + + subgraph SPIRE_Build4["SPIRE build"] + SPIRE_Fork4["Cosmian/spire fork
kmip-upstream-authority"] + SPIRE_Bin4["spire-server binary"] + end + + subgraph KMS5["KMS"] + HTTPS4["HTTPS API :9997"] + KMIP_Socket2["Binary KMIP TCP
socket_server :5697"] + end + + subgraph Certs4["mTLS certificates"] + CA4["Test CA P-384"] + KMS_Cert4["KMS server cert"] + SP_Cert4["SPIRE client cert"] + end + + subgraph Provisioned2["Provisioned in KMS"] + RootKey2["Root CA key pair
uid=spire-kmip-upstream-ca-key"] + RootCert2["Self-signed root CA certificate"] + end + + SPIRE_Fork4 --> |go build| SPIRE_Bin4 + CKMS3 --> |certify + key create| KMS5 + KMS5 --> RootKey2 + KMS5 --> RootCert2 + SPIRE_Bin4 --> |UpstreamAuthority plugin kmip| KMIP_Socket2 + SPIRE_Bin4 --> |disk KeyManager| Disk2[(keys.json)] + SPIRE_Bin4 --> |mTLS| Certs4 + KMIP_Socket2 --> |mTLS| Certs4 +``` + +#### Sequence Diagram + +```mermaid +sequenceDiagram + participant Test as test script + participant Cargo as cargo build + participant Go as go build + participant OpenSSL as openssl + participant CKMS as ckms CLI + participant KMS as KMS socket_server + HTTPS + participant SP as SPIRE Server disk KM + kmip UA + + Test->>Cargo: build KMS server non-fips + Test->>Cargo: build ckms CLI + Test->>Go: clone Cosmian/spire fork kmip-upstream-authority + Test->>Go: go build spire-server + Test->>OpenSSL: generate CA + KMS cert + SPIRE client cert + Test->>KMS: write kms.toml HTTPS :9997 socket_server :5697 mTLS + Test->>KMS: start KMS + KMS-->>Test: HTTPS ready, KMIP TCP ready + Test->>CKMS: ec keys create CA key uid + CKMS->>KMS: Create Key Pair P-384 + Test->>CKMS: certificates certify self-signed root CA + CKMS->>KMS: Certify + Register + Test->>SP: write server.conf KeyManager disk UpstreamAuthority kmip + Test->>SP: spire-server run -config server.conf + SP->>KMS: KMIP Sign using root CA key issue intermediate CA + KMS-->>SP: signed intermediate CA cert + Test->>SP: spire-server healthcheck + SP-->>Test: healthy + Test->>SP: spire-server token generate + SP-->>Test: join token proves UpstreamAuthority signed CA +``` + +--- + +### `test:spire-kmip` + +**Description:** Orchestrator that runs both KeyManager and UpstreamAuthority suites sequentially. + +#### Architecture Overview + +```mermaid +graph TB + subgraph Orchestrator2["spire-kmip orchestrator"] + Task1["test:spire-kmip-key-manager"] + Task2["test:spire-kmip-upstream-authority"] + end + + subgraph KM_Task["KeyManager Task"] + KM_KMS["KMS :9998 HTTPS / :5696 KMIP"] + KM_SP["SPIRE Server
eviden-kms-plugins"] + end + + subgraph UA_Task["UpstreamAuthority Task"] + UA_KMS["KMS :9997 HTTPS / :5697 KMIP"] + UA_SP["SPIRE Server
kmip-upstream-authority"] + end + + Orchestrator2 --> Task1 + Orchestrator2 --> Task2 + Task1 --> KM_KMS + Task1 --> KM_SP + Task2 --> UA_KMS + Task2 --> UA_SP +``` + +#### Sequence Diagram + +```mermaid +sequenceDiagram + participant Orch as spire-kmip orchestrator + participant KM as test:spire-kmip-key-manager + participant UA as test:spire-kmip-upstream-authority + + Orch->>KM: run_suite KeyManager + Note over KM: Builds KMS + SPIRE fork KeyManager
Starts KMS with socket_server
Runs SPIRE with kmip KeyManager
Verifies key creation + KM-->>Orch: PASSED or FAILED + Orch->>UA: run_suite UpstreamAuthority + Note over UA: Builds KMS + SPIRE fork UpstreamAuthority
Provisions root CA in KMS
Runs SPIRE with kmip UpstreamAuthority
Verifies CA signing + UA-->>Orch: PASSED or FAILED + alt Any suite failed + Orch-->>Orch: exit 1 + else All passed + Orch-->>Orch: exit 0 + end +``` + +--- + +### `test:spire-pki` + +**Description:** KMS PKI capability validation — M-01 through M-10 from Aembit Capability Validation Test Plan. + +#### Architecture Overview + +```mermaid +graph TB + subgraph Host5["Host"] + CKMS4["ckms CLI"] + AuthVrf3["auth-verifier
:8443"] + TestScript["test_pki.sh"] + end + + subgraph KMS6["KMS :9998"] + API["HTTPS API"] + SQLite2[(SQLite DB)] + end + + AuthVrf3 --> |AppRole provisioning| KMS6 + CKMS4 --> |certify, keys create| KMS6 + TestScript --> CKMS4 + TestScript --> AuthVrf3 +``` + +#### Sequence Diagram + +```mermaid +sequenceDiagram + participant Test as test script + participant Cargo as cargo build + participant Auth as auth-verifier + participant KMS as KMS :9998 + participant CKMS as ckms CLI + participant TestPKI as test_pki.sh + + Test->>Cargo: build KMS server non-fips + Test->>Cargo: build auth-verifier + Test->>Cargo: build ckms CLI + Test->>Auth: start auth-verifier :8443 + Test->>KMS: start KMS :9998 + Test->>CKMS: certificates certify vault_pki_ca_cert + CKMS->>KMS: create root CA key + cert + Test->>Auth: provision AppRoles + Auth-->>Test: ROLE_ID_A, SECRET_ID_A + Test->>TestPKI: run test_pki.sh + Note over TestPKI: M-01 / PKI-06 Self-signed cert prohibition
M-02 / PKI-11 TLS version enforcement
M-03 / PKI-12 Algorithm policy change
M-04 / PKI-04 Zero-downtime CA rotation
M-05 / PKI-17 Trust re-establishment
M-06 / OBS-05 PKI signing latency less-than 500ms
M-07 / INFO-2 DPoP signing key lifecycle
M-08 / RES-08 Legacy + SPIFFE coexistence
M-09 / PKI-03 Client/server certificate parity
M-10 / WI-05 Revocation propagation + TestPKI-->>Test: all scenarios passed +``` + +--- + +### `test:spire-sds` + +**Description:** PKI-10 Service mesh SDS delivery — Envoy + SPIRE + Cosmian KMS. + +#### Architecture Overview + +```mermaid +graph TB + subgraph DockerCompose["Docker Compose profile: spire"] + SP_Srv2["SPIRE Server A"] + SP_Agt2["SPIRE Agent A
unix socket + SDS"] + Envoy_U2["Envoy upstream
mTLS server"] + Envoy_D2["Envoy downstream
mTLS client"] + end + + subgraph Host6["Host"] + AuthVrf4["auth-verifier
:8443"] + CKMS5["ckms CLI"] + TestSDS2["test_sds.sh"] + end + + subgraph KMS7["KMS :9998"] + API2["HTTPS API"] + end + + KMS7 --> |signs intermediate CA| SP_Srv2 + SP_Srv2 --> |issues X.509-SVIDs| SP_Agt2 + SP_Agt2 --> |SDS delivery| Envoy_U2 + SP_Agt2 --> |SDS delivery| Envoy_D2 + Envoy_D2 --> |mTLS| Envoy_U2 + AuthVrf4 --> |AppRole provisioning| KMS7 + CKMS5 --> |certify, provision| KMS7 + TestSDS2 --> |orchestrates| DockerCompose +``` + +#### Sequence Diagram + +```mermaid +sequenceDiagram + participant Test as test script + participant Cargo as cargo build + participant Auth as auth-verifier + participant KMS as KMS :9998 + participant CKMS as ckms CLI + participant SP_Srv as SPIRE Server A + participant SP_Agt as SPIRE Agent A + participant Envoy_U as Envoy upstream + participant Envoy_D as Envoy downstream + participant TestSDS as test_sds.sh + + Test->>Cargo: build KMS server, auth-verifier, ckms + Test->>Auth: start auth-verifier :8443 + Test->>KMS: start KMS :9998 + Test->>CKMS: certificates certify vault_pki_ca_cert + CKMS->>KMS: create root CA key + cert + Test->>Auth: provision AppRoles + Auth-->>Test: ROLE_ID_A, SECRET_ID_A + Test->>SP_Srv: docker compose up spire-server-a Vault AppRole + SP_Srv->>SP_Srv: ready http://localhost:8080/ready + Test->>SP_Srv: spire-server token generate + SP_Srv-->>Test: join token + Test->>SP_Agt: docker compose up spire-agent-a join_token + SP_Agt->>SP_Srv: Node attestation + SVID fetch + SP_Agt-->>Test: ready http://localhost:8082/ready + Test->>TestSDS: run test_sds.sh + TestSDS->>SP_Srv: register workload entries + TestSDS->>Envoy_U: start Envoy SDS upstream + TestSDS->>Envoy_D: start Envoy SDS downstream + Envoy_U->>SP_Agt: fetch tls_certificate via SDS + Envoy_D->>SP_Agt: fetch tls_certificate via SDS + SP_Agt-->>Envoy_U: X.509-SVID cert + key + SP_Agt-->>Envoy_D: X.509-SVID cert + key + Envoy_D->>Envoy_U: mTLS handshake with SDS-provided certs + Envoy_U-->>Envoy_D: connection established + TestSDS-->>Test: PKI-10 SDS delivery passed +``` + +--- + +## 7. PKI & Revocation + +**Tasks:** `test:ocsp`, `test:pki-revocation` + +### `test:ocsp` + +**Description:** OCSP responder (RFC 6960) black-box test suite using `openssl ocsp` and `curl`. Tests GET/POST, nonce, caching, delegated signing, and root-compromise cascade. + +#### Architecture Overview + +```mermaid +graph TB + subgraph Host7["Host"] + OpenSSL5["openssl ocsp client"] + Curl["curl"] + CKMS6["ckms CLI"] + end + + subgraph KMS8["Fresh KMS per scenario"] + OCSP["OCSP responder
GET /ocsp/{b64url}
POST /ocsp/"] + Store[("SQLite
CA + leaf certs")] + end + + CKMS6 --> |certify, revoke, export| Store + OpenSSL5 --> |DER req| OCSP + Curl --> |b64url DER| OCSP + OCSP --> Store +``` + +#### Sequence Diagram + +```mermaid +sequenceDiagram + participant Test as ocsp test script + participant KMS as Fresh KMS + participant CKMS as ckms + participant OpenSSL as openssl ocsp + participant Curl as curl + + Test->>Test: Build KMS + ckms + loop Each scenario + Test->>KMS: start with ocsp_enabled=true + policy + Test->>CKMS: issue_ca, issue_leaf, issue_delegate + CKMS->>KMS: store certs + Test->>OpenSSL: ocsp -issuer ca -cert leaf -url KMS/ocsp/ + OpenSSL->>KMS: POST DER OCSPRequest + KMS-->>OpenSSL: OCSPResponse good/revoked/unknown + Test->>Curl: GET /ocsp/{b64url} + Curl->>KMS: base64url DER request + KMS-->>Curl: HTTP 200 + Cache-Control + ETag + end + Test->>KMS: stop +``` + +--- + +### `test:pki-revocation` + +**Description:** PKI revocation black-box test — CDP/CRL, AIA/OCSP, CA-compromise cascade. + +#### Architecture Overview + +```mermaid +graph TB + subgraph Host8["Host"] + OpenSSL6["openssl verify
openssl ocsp"] + CKMS7["ckms CLI"] + end + + subgraph KMS9["Fresh KMS"] + OCSP2["OCSP responder"] + CRL["CRL endpoint
auto-generated"] + Store2[("SQLite
cert chain")] + end + + CKMS7 --> |certify, revoke, validate| Store2 + OpenSSL6 --> |verify -crl_check| CRL + OpenSSL6 --> |ocsp| OCSP2 +``` + +#### Sequence Diagram + +```mermaid +sequenceDiagram + participant Test as pki-revocation script + participant KMS as Fresh KMS + participant CKMS as ckms + participant OpenSSL as openssl verify / ocsp + + Test->>KMS: start with ocsp_enabled=true kms_public_url set + Test->>CKMS: issue_ca root, issue_ca intermediate, issue_leaf + CKMS->>KMS: store chain + Test->>OpenSSL: verify -crl_check leaf + OpenSSL->>KMS: fetch CRL from CDP + KMS-->>OpenSSL: CRL includes revoked certs + Test->>CKMS: revoke intermediate keyCompromise + Test->>OpenSSL: verify -crl_check leaf + OpenSSL-->>Test: invalid / revoked + Test->>CKMS: validate leaf + CKMS->>KMS: internal cascade check + KMS-->>CKMS: Invalid compromised root ancestor +``` + +--- + +## 8. Audit, SIEM, Monitoring & Observability + +**Tasks:** `test:audit`, `test:audit-compat-generate-fixture`, `test:audit-compat-opensearch`, `test:audit-compat-splunk`, `test:cef-format`, `test:cef-syslog`, `test:cef-tcp-syslog`, `test:siem-fluent-bit`, `test:siem-filebeat`, `test:siem-cef-syslog`, `test:siem-cef-tcp-syslog`, `test:monitoring`, `test:otel` + +**Orchestrators:** `test:cef` (format + UDP + TCP), `test:siem` (fluent-bit + filebeat) + +### `test:audit` + +**Description:** Tamper-evident JSONL audit log + HTTP audit middleware capture, followed by the OpenSearch compatibility sub-task (always) and the Splunk one (only when `SPLUNK_PASSWORD` is set). + +#### Architecture Overview + +```mermaid +graph TB + subgraph Host9["Host"] + KMS10["KMS server
audit middleware"] + TestAudit["test_audit_log.sh"] + end + + subgraph Outputs["Audit Outputs"] + JSONL["/tmp/kms-audit-*.jsonl
hash chain"] + end + + subgraph SIEMs["SIEM Backends"] + OpenSearch["OpenSearch
ephemeral Docker"] + Splunk["Splunk
ephemeral Docker"] + end + + TestAudit --> KMS10 + KMS10 --> JSONL + TestAudit --> JSONL + TestAudit --> OpenSearch + TestAudit --> |only if SPLUNK_PASSWORD| Splunk +``` + +#### Sequence Diagram + +```mermaid +sequenceDiagram + participant Test as audit test script + participant KMS as KMS with audit middleware + participant JSONL as JSONL log file + participant OpenSearch as OpenSearch Docker + participant Splunk as Splunk Docker + + Test->>KMS: start KMS with audit logging + Test->>KMS: perform KMIP operations + KMS->>JSONL: append tamper-evident JSONL line + KMS->>JSONL: hash chain link + JSONL-->>Test: file exists, hash verified + Test->>OpenSearch: docker run opensearch + Test->>OpenSearch: ingest JSONL via Python + OpenSearch-->>Test: all fields indexed correctly + alt SPLUNK_PASSWORD set + Test->>Splunk: docker run splunk + Test->>Splunk: HEC ingest JSONL + Splunk-->>Test: sourcetype _json OK + end +``` + +--- + +### `test:audit-compat-opensearch` / `test:audit-compat-splunk` + +These take a pre-generated KMS JSONL audit file and verify full-field compatibility with the target SIEM backend. + +#### Sequence Diagram + +```mermaid +sequenceDiagram + participant Script as audit-compat script + participant Fixture as audit-compat-generate-fixture + participant Docker as ephemeral container + participant Python as validate_audit_compat.py + + Script->>Fixture: generate live JSONL if --file omitted + Fixture->>Docker: run KMS + perform ops + Docker-->>Fixture: JSONL file + Fixture-->>Script: /tmp/kms-audit-*.jsonl + Script->>Docker: start OpenSearch / Splunk container + Docker-->>Script: HTTP ready + Script->>Python: validate --backend opensearch|splunk + Python->>Docker: index / search all fields + Docker-->>Python: mappings + hits + Python-->>Script: PASS all fields compatible +``` + +--- + +### CEF Tests (`test:cef-format`, `test:cef-syslog`, `test:cef-tcp-syslog`) + +**Description:** CEF v27 format validation and syslog transport (UDP and TCP/rsyslog). + +#### Architecture Overview + +```mermaid +graph TB + subgraph Host10["Host"] + KMS11["KMS server
non-fips build"] + CEFScript["CEF test script
curl / rsyslog receiver"] + end + + subgraph Receivers["Syslog Receivers"] + UDP["UDP syslog
:514"] + TCP["TCP syslog
rsyslog :5514"] + end + + KMS11 --> |CEF v27| UDP + KMS11 --> |CEF v27| TCP +``` + +#### Sequence Diagram + +```mermaid +sequenceDiagram + participant Test as CEF test script + participant KMS as KMS non-fips + participant Receiver as syslog receiver + + Test->>KMS: start KMS with CEF export + Test->>Receiver: start Docker UDP/TCP syslog + Test->>KMS: trigger KMIP operations + KMS->>Receiver: CEF v27 syslog messages + Receiver-->>Test: captured lines + Test->>Test: validate format, extensions, keys +``` + +--- + +### SIEM Tests (`test:siem-fluent-bit`, `test:siem-filebeat`) + +**Description:** Fluent Bit JSONL file-tailing and Filebeat → Elasticsearch forwarding. + +#### Architecture Overview + +```mermaid +graph TB + subgraph Host11["Host"] + KMS12["KMS server
non-fips build"] + FluentBit["Fluent Bit
Docker"] + Filebeat["Filebeat
Docker"] + end + + subgraph Search["Search Backend"] + ES["Elasticsearch
:9200"] + end + + KMS12 --> |JSONL audit log| FluentBit + KMS12 --> |JSONL audit log| Filebeat + FluentBit --> |parsed events| ES + Filebeat --> |parsed events| ES +``` + +#### Sequence Diagram + +```mermaid +sequenceDiagram + participant Test as SIEM test script + participant KMS as KMS non-fips + participant Agent as Fluent Bit / Filebeat + participant ES as Elasticsearch + + Test->>ES: ensure elasticsearch running + Test->>KMS: start KMS with JSONL audit + Test->>Agent: start Docker agent + Test->>KMS: trigger KMIP operations + KMS->>Agent: write JSONL lines + Agent->>ES: POST parsed documents + ES-->>Test: index contains expected events +``` + +--- + +### `test:monitoring` + +**Description:** Monitoring stack — OTel collector → VictoriaMetrics → Grafana. + +#### Architecture Overview + +```mermaid +graph TB + subgraph Host12["Host"] + KMS13["KMS server
OTLP exporter"] + end + + subgraph Monitoring["Monitoring Stack"] + OTel["OTel Collector
contrib"] + VM["VictoriaMetrics"] + Grafana["Grafana
:3000"] + end + + KMS13 --> |OTLP/gRPC| OTel + OTel --> |remote_write| VM + Grafana --> |query| VM +``` + +#### Sequence Diagram + +```mermaid +sequenceDiagram + participant Test as monitoring test script + participant KMS as KMS with OTLP + participant OTel as OTel Collector + participant VM as VictoriaMetrics + participant Grafana as Grafana + + Test->>OTel: docker run otel-collector + Test->>VM: docker run victoriametrics + Test->>Grafana: docker run grafana + Test->>KMS: start KMS with otlp URL + KMS->>OTel: push metrics OTLP/gRPC + OTel->>VM: remote_write + Test->>VM: query kms_server_uptime_seconds_total + VM-->>Test: non-zero value + Test->>Grafana: GET /api/health + Grafana-->>Test: HTTP 200 +``` + +--- + +### `test:otel` + +**Description:** OTLP/OpenTelemetry export integration test. Validates KMS metrics export to an OTel collector. + +#### Architecture Overview + +```mermaid +graph TB + subgraph Host13["Host"] + KMS14["KMS server
cargo run"] + Collector["OTel Collector
Docker"] + end + + KMS14 --> |OTLP/gRPC :4317| Collector + Collector --> |:8889 /metrics| Prometheus_Scrape +``` + +#### Sequence Diagram + +```mermaid +sequenceDiagram + participant Test as otel test script + participant KMS as KMS cargo run + participant Collector as OTel Collector + + Test->>Collector: docker run collector + Test->>KMS: write config with otlp endpoint + Test->>KMS: cargo run KMS + KMS->>Collector: OTLP metrics export + Collector-->>Test: /metrics endpoint responds + Test->>Test: scrape, assert expected series +``` + +--- + +## 9. Database TDE Integrations + +**Tasks:** `test:ase`, `test:db2`, `test:edb-tde`, `test:docker-oracle`, `test:iris` + +| Task | Database | Protocol | FIPS? | Requirement | +|------|----------|----------|-------|-------------| +| `ase` | SAP ASE | KMIP | no | Docker (builds the `cosmian-ase-kmip` image on the fly) | +| `db2` | IBM Db2 LUW | KMIP | no | Docker | +| `edb-tde` | EDB Postgres | KMIP | no | Docker + EDB_SUBSCRIPTION_TOKEN | +| `docker-oracle` | Oracle | TDE | no | Docker, non-fips amd64 only; `DOCKER_IMAGE_NAME`, `ORACLE_KMS_DEMO_USER_PASS`, `COSMIAN_HSM_PIN` | +| `iris` | InterSystems IRIS | mTLS | no | Docker | + +### Architecture Overview + +```mermaid +graph TB + subgraph Host14["Host"] + Nix5["Nix shell
FIPS OpenSSL"] + KMS15["Cosmian KMS
local build"] + TestScripts2["test_*_tde.sh"] + end + + subgraph DBContainers["Database Containers"] + ASE["SAP ASE
Docker"] + DB2["IBM Db2
Docker"] + EDB["EDB Postgres
Docker"] + Oracle["Oracle
Docker"] + IRIS["InterSystems IRIS
Docker"] + end + + Nix5 --> KMS15 + TestScripts2 --> KMS15 + TestScripts2 --> ASE + TestScripts2 --> DB2 + TestScripts2 --> EDB + TestScripts2 --> Oracle + TestScripts2 --> IRIS + KMS15 <--> |KMIP / mTLS| ASE + KMS15 <--> |KMIP| DB2 + KMS15 <--> |KMIP| EDB + KMS15 <--> |TDE| Oracle + KMS15 <--> |mTLS| IRIS +``` + +### Sequence Diagram (generic TDE pattern) + +```mermaid +sequenceDiagram + participant Test as TDE test script + participant Nix as Nix shell + participant KMS as Cosmian KMS + participant DB as Database Container + + Test->>Nix: ensure_nix_shell + Test->>KMS: build + start on free port + Test->>DB: docker run database + DB-->>Test: DB ready + Test->>KMS: provision KEK via ckms / KMIP + KMS-->>Test: key UID + Test->>DB: configure TDE with KMS URL + key UID + DB->>KMS: KMIP: Create / Get / Encrypt / Decrypt + KMS-->>DB: key material / encrypted page keys + Test->>DB: create encrypted tablespace + DB-->>Test: success + Test->>DB: verify data readable with TDE key + DB-->>Test: plaintext confirmed +``` + +--- + +## 10. Client & Protocol Integrations + +**Tasks:** `test:jose`, `test:kmip-go`, `test:luks`, `test:openssh`, `test:pykmip`, `test:veracrypt` + +| Task | Client / Protocol | FIPS? | Notes | +|------|-------------------|-------|-------| +| `jose` | JOSE REST API + jwcrypto | no | Nix shell, KMS server | +| `kmip-go` | ovh/kmip-go (KMIP 1.0–1.4) | no | Go toolchain, binary TTLV | +| `luks` | LUKS disk encryption PKCS#11 | no | WITH_LUKS=1, HSM | +| `openssh` | OpenSSH PKCS#11 | no | WITH_OPENSSH=1, HSM | +| `pykmip` | PyKMIP + Synology DSM | no | WITH_PYTHON=1 | +| `veracrypt` | VeraCrypt PKCS#11 | no | `veracrypt` binary on PATH | + +### Architecture Overview + +```mermaid +graph TB + subgraph Host15["Host"] + KMS16["Cosmian KMS
local build"] + Nix6["Nix shell
FIPS OpenSSL + PKCS#11"] + end + + subgraph Clients["External Clients"] + JOSE["jwcrypto / Python
JOSE REST API"] + KMIPGO["ovh/kmip-go
binary TTLV"] + LUKS2["cryptsetup
LUKS format"] + OpenSSH["ssh-keygen
ssh-agent"] + PyKMIP["PyKMIP client
Synology DSM"] + VeraCrypt["VeraCrypt
volume mount"] + end + + Nix6 --> KMS16 + KMS16 <--> |HTTPS JOSE| JOSE + KMS16 <--> |TCP 5696 KMIP| KMIPGO + KMS16 <--> |PKCS#11| LUKS2 + KMS16 <--> |PKCS#11| OpenSSH + KMS16 <--> |KMIP JSON| PyKMIP + KMS16 <--> |PKCS#11| VeraCrypt +``` + +### Sequence Diagram (JOSE example; others analogous) + +```mermaid +sequenceDiagram + participant Test as jose test script + participant Nix as Nix shell + participant KMS as KMS server + participant JOSE as jwcrypto client + + Test->>Nix: ensure_nix_shell + Test->>KMS: build + start + Test->>JOSE: generate RSA / EC keypair + JOSE->>KMS: JOSE REST create JWK + KMS-->>JOSE: key handle + JOSE->>KMS: sign / verify / encrypt / decrypt + KMS-->>JOSE: JOSE response + Test->>JOSE: assert jwcrypto interop +``` + +--- + +## 11. Kubernetes E2E Tests + +**Tasks:** `test:k8s:_default`, `test:k8s:plugin`, `test:k8s:operator`, `test:k8s:csi-provider`, `test:k8s:kms-image`, `test:k8s:operator-image`, `test:k8s:csi-provider-image`, `test:helm` + +### `test:k8s:_default` + +**Description:** Orchestrator that runs all Kubernetes E2E tests (plugin, plugin with `--mtls`, operator, CSI provider); a failing step is recorded and the remaining ones still run. + +#### Architecture Overview + +```mermaid +graph TB + subgraph Orchestrator3["k8s:_default orchestrator"] + Plugin["test:k8s:plugin"] + PluginMTLS["test:k8s:plugin --mtls"] + Operator["test:k8s:operator"] + CSI["test:k8s:csi-provider"] + end + + subgraph Minikube["Minikube cluster"] + KMS_Pod["KMS Pod
Helm chart"] + Plugin_Bin["kubernetes-kms-plugin
Minikube node"] + Operator_Job["Operator Job
inject secret"] + CSI_DS["CSI Provider
DaemonSet"] + end + + Plugin --> KMS_Pod + PluginMTLS --> KMS_Pod + Operator --> KMS_Pod + CSI --> KMS_Pod +``` + +--- + +### `test:k8s:plugin` + +**Description:** Kubernetes KMS Provider Plugin — etcd Secret encryption. Deploys KMS in-cluster, creates a KEK, installs the plugin binary on the Minikube node as a systemd service, and verifies etcd Secret encryption. gRPC is used only between kube-apiserver and the plugin (KMS v2 API over a unix socket); the plugin talks to the KMS over plain HTTP, or over HTTPS with client certificates when the task runs with `--mtls`. + +#### Architecture Overview + +```mermaid +graph TB + subgraph Minikube2["Minikube Node"] + PluginSvc["kubernetes-kms-plugin
systemd
unix socket"] + K8sAPI["kube-apiserver"] + etcd[("etcd
encrypted secrets")] + end + + subgraph K8s_NS["kms-plugin-e2e namespace"] + KMS_Pod2["Cosmian KMS Pod
Helm"] + end + + K8sAPI --> |gRPC KMS v2
unix socket| PluginSvc + PluginSvc --> |HTTP, or HTTPS + client certs with --mtls| KMS_Pod2 + K8sAPI --> |encrypted write| etcd +``` + +#### Sequence Diagram + +```mermaid +sequenceDiagram + participant Test as k8s:plugin script + participant Helm as Helm + participant KMS as KMS Pod + participant Node as Minikube node + participant Plugin as kubernetes-kms-plugin + participant API as kube-apiserver + participant etcd as etcd + + Test->>Helm: deploy KMS in namespace + Helm-->>Test: ClusterIP known + Test->>KMS: port-forward + create KEK + KMS-->>Test: KEK UID + Test->>Node: install plugin binary + config + Test->>Node: systemctl start plugin + Test->>Node: enable etcd encryption on kube-apiserver + Test->>API: kubectl create secret + API->>Plugin: gRPC Encrypt (unix socket) + Plugin->>KMS: KMIP Encrypt with the KEK (HTTP, or HTTPS + client certs with --mtls) + KMS-->>Plugin: encrypted DEK + Plugin-->>API: encrypted DEK + API->>etcd: write encrypted Secret + Test->>etcd: read raw value, expect k8s:enc:kms:v2 prefix + Test->>API: kubectl get secret + API->>Plugin: gRPC Decrypt (unix socket) + Plugin->>KMS: KMIP Decrypt with the KEK + KMS-->>Plugin: plaintext DEK + Plugin-->>API: plaintext DEK + API-->>Test: decrypted secret +``` + +--- + +### `test:k8s:operator` + +**Description:** KMS Operator — injects a SecretData value from KMS into a workload Pod via initContainer. + +#### Architecture Overview + +```mermaid +graph TB + subgraph Minikube3["Minikube"] + KMS_Pod3["KMS Pod"] + Job["Kubernetes Job
initContainer inject
+ busybox verify"] + end + + Job --> |HTTP inject: read SecretData| KMS_Pod3 +``` + +#### Sequence Diagram + +```mermaid +sequenceDiagram + participant Test as k8s:operator script + participant KMS as KMS Pod + participant Job as Operator Job + participant Verify as busybox verify + + Test->>KMS: deploy + create SecretData + KMS-->>Test: Secret UID + Test->>Job: kubectl apply Job + Job->>KMS: initContainer inject: read SecretData over HTTP + KMS-->>Job: SecretData value + Job->>Job: write /output/injected-secret (shared emptyDir) + Job->>Verify: cat /output/injected-secret + Verify-->>Test: value matches +``` + +--- + +### `test:k8s:csi-provider` + +**Description:** KMS CSI Provider — Secrets Store CSI Driver integration. DaemonSet in kube-system mounts secrets as volumes. + +#### Architecture Overview + +```mermaid +graph TB + subgraph kube_system["kube-system"] + CSI_Driver["Secrets Store CSI Driver"] + Provider_DS["cosmian-kms-csi-provider
DaemonSet
unix socket"] + end + + subgraph Test_NS["kms-csi-e2e"] + KMS_Pod4["KMS Pod"] + end + + subgraph Test_Pods["Test Pods"] + Pod1["csi-known-pod
secret mounted"] + Pod2["csi-rotate-pod
key rotated"] + Pod3["csi-revoked-pod
mount denied"] + end + + CSI_Driver --> |gRPC| Provider_DS + Provider_DS --> |HTTP| KMS_Pod4 + Pod1 --> |volume mount| CSI_Driver + Pod2 --> |volume mount| CSI_Driver + Pod3 --> |volume mount| CSI_Driver +``` + +#### Sequence Diagram + +```mermaid +sequenceDiagram + participant Test as k8s:csi-provider script + participant KMS as KMS Pod + participant Driver as Secrets Store CSI Driver + participant Provider as CSI Provider DaemonSet + participant Pod as Test Pod + + Test->>KMS: deploy + create secrets + keys + Test->>Driver: helm install secrets-store-csi-driver + Test->>Provider: kubectl apply DaemonSet + Provider->>KMS: fetch secret value + KMS-->>Provider: plaintext + Test->>Pod: kubectl apply pod + Pod->>Driver: mount request + Driver->>Provider: gRPC Mount + Provider-->>Pod: secret as file + Test->>Pod: verify file contents +``` + +--- + +## 12. Web UI & WASM + +**Tasks:** `test:ui`, `test:ui-auth`, `test:ui-oidc`, `test:wasm` + +**Orchestrator:** `test:ui` (runs standard + auth + OIDC sequentially). The auth suite is skipped in FIPS mode (the auth-verifier is not FIPS-aware) or when the `authentication` submodule is absent; the OIDC suite needs Auth0 secrets and skips cleanly without them. + +### Architecture Overview + +```mermaid +graph TB + subgraph Host16["Host"] + KMS17["KMS server
non-fips"] + AuthVrf5["auth-verifier
optional"] + Playwright2["Playwright E2E
Chromium"] + end + + subgraph UI["Web UI"] + React["React 19 + Vite"] + WASM["cosmian_kms_client_wasm
pkg"] + end + + KMS17 --> |HTTPS| React + AuthVrf5 --> |OIDC| React + React --> WASM + Playwright2 --> |automate browser| React +``` + +### Sequence Diagram (`test:ui` orchestrator) + +```mermaid +sequenceDiagram + participant Test as ui test script + participant Nix as Nix shell + participant KMS as KMS server + participant Auth as auth-verifier + participant PW as Playwright + + Test->>Nix: ensure_nix_shell WITH_WASM=1 + Test->>KMS: build + start + Test->>Auth: build + start auth-verifier + Test->>PW: run standard E2E suite + PW->>KMS: create keys, objects, certificates + KMS-->>PW: responses + Test->>PW: run auth E2E suite + PW->>Auth: login flow + Auth-->>PW: JWT tokens + Test->>PW: run OIDC E2E suite + PW->>KMS: OIDC callback flow + KMS-->>PW: session cookie +``` + +### WASM Build Sequence + +```mermaid +sequenceDiagram + participant Test as wasm test script + participant Nix as Nix shell + participant Rust as wasm-pack test --node + participant Build as wasm-pack build --target web + participant UI as UI source tree + + Test->>Nix: ensure_nix_shell WITH_WASM=1 (only if wasm-pack is missing or Node < 22) + Test->>Rust: run wasm-bindgen unit tests + Rust-->>Test: pass + Test->>Build: build web-target WASM package + Build-->>Test: pkg/ directory + Test->>UI: copy pkg to ui/src/wasm/pkg + Test->>UI: pnpm install + check + test:unit + UI-->>Test: pass +``` + +--- + +## 13. Docker, Helm & Packaging + +**Tasks:** `test:docker`, `test:helm`, `test:k8s:kms-image`, `test:k8s:operator-image`, `test:k8s:csi-provider-image` + +### `test:docker` + +**Description:** Docker image smoke tests. Does **not** build an image: it exports `KMS_TLS_CONFIG_FLAVOR` (`fips` or `non_fips`, derived from `--variant`) and runs `.mise/scripts/test/test_docker_image.sh`, which starts the already-built and already-loaded image named by `DOCKER_IMAGE_NAME` through `docker compose -f .mise/scripts/docker-compose.yml up -d --wait`, probes TLS with `openssl s_client`, drives the stack with `ckms` (run through `cargo run -p ckms`), and checks the FIPS/non-FIPS variant reported by `/version`. The image itself is produced beforehand (Nix build via `mise run build:docker --load`, or the packaging CI workflow). + +#### Architecture Overview + +```mermaid +graph TB + subgraph Host17["Host"] + TestScript2["test_docker_image.sh"] + Docker2["Docker daemon
image already loaded
DOCKER_IMAGE_NAME"] + Ckms["ckms via cargo run"] + OpenSSL["openssl s_client"] + end + + subgraph Compose["docker compose
.mise/scripts/docker-compose.yml"] + KMS18["KMS services
no-auth, TLS 1.2/1.3, kms-no-conf,
non-root, oracle profile, ..."] + end + + TestScript2 --> |up -d --wait| Docker2 + Docker2 --> KMS18 + OpenSSL --> |TLS 1.2 / 1.3 handshakes| KMS18 + Ckms --> |sym keys create| KMS18 + TestScript2 --> |curl /version, /ui/index.html| KMS18 +``` + +#### Sequence Diagram + +```mermaid +sequenceDiagram + participant Task as test:docker task + participant Script as test_docker_image.sh + participant Compose as docker compose + participant KMS as KMS containers + participant Ckms as ckms (cargo run) + + Task->>Task: export KMS_TLS_CONFIG_FLAVOR + Task->>Script: run + Script->>Compose: up -d --wait (image from DOCKER_IMAGE_NAME) + Compose->>KMS: start services + KMS-->>Compose: healthchecks pass + Script->>Ckms: sym keys create (HTTP and mTLS configs) + Ckms->>KMS: create keys + Script->>KMS: openssl s_client TLS 1.2 / 1.3 (TLS 1.2 rejected on TLS 1.3-only ports) + KMS-->>Script: handshake results + Script->>KMS: curl /ui/index.html, /version (no-config, non-root) + KMS-->>Script: HTTP 200 + version JSON + Script->>KMS: assert FIPS or non-FIPS build in /version + Script->>Compose: load balancer shutdown + Oracle TDE HSM tests +``` + +--- + +### `test:helm` + +**Description:** Helm chart lint, template validation, and optional E2E deployment on a live Kubernetes cluster. + +#### Architecture Overview + +```mermaid +graph TB + subgraph Host18["Host"] + Helm["helm CLI"] + Kubectl["kubectl"] + end + + subgraph Charts["charts/cosmian-kms"] + Chart["Chart.yaml
values.yaml
templates/"] + end + + subgraph Cluster["Live K8s Cluster
option --e2e"] + Release["Helm Release
cosmian-kms-e2e"] + end + + Helm --> |lint / template| Chart + Helm --> |install --wait| Cluster + Kubectl --> |cluster-info
helm test| Cluster +``` + +#### Sequence Diagram + +```mermaid +sequenceDiagram + participant Test as helm test script + participant Helm as helm + participant Chart as cosmian-kms chart + participant Cluster as Live K8s + + Test->>Helm: helm lint --strict + Helm->>Chart: validate structure + Chart-->>Helm: OK + Test->>Helm: helm template (defaults, TLS, Ingress, minimal, NetworkPolicy) + Helm->>Chart: render manifests + Chart-->>Helm: valid YAML + alt --e2e + Test->>Cluster: kubectl cluster-info + Cluster-->>Test: reachable + Test->>Helm: helm install cosmian-kms-e2e + Helm->>Cluster: deploy pods + svc + Cluster-->>Helm: ready + Test->>Helm: helm test (wget probe) + Helm->>Cluster: run test pod + Cluster-->>Helm: pass + end +``` + +--- + +## 14. Miscellaneous + +### `test:vectors-rekey` + +**Description:** Generate ReKey and ReKeyKeyPair test vectors into `test_data/`. + +#### Sequence Diagram + +```mermaid +sequenceDiagram + participant Test as vectors-rekey script + participant Script2 as generate_rekey_vectors.sh + participant Data as test_data/ + + Test->>Script2: execute + Script2->>Data: write ReKey vectors + Script2->>Data: write ReKeyKeyPair vectors + Data-->>Test: files created +``` + +--- + +### `test:load-balancer` + +**Description:** nginx load-balancer graceful shutdown test. Runs three KMS containers (`kms1`..`kms3`, sharing one PostgreSQL) behind `nginx-load-balancer` from `.mise/scripts/docker-compose.yml` and checks that `/health` through nginx stays 200 while at least one backend is up, answers 502/504 with none, and recovers to 200 once the backends are restored. The same script also runs at the end of `test:docker`. + +#### Architecture Overview + +```mermaid +graph TB + subgraph Compose2["docker compose
.mise/scripts/docker-compose.yml"] + Nginx["nginx-load-balancer"] + KMS_1["kms1"] + KMS_2["kms2"] + KMS_3["kms3"] + PG[("postgres")] + end + + Nginx --> |upstream| KMS_1 + Nginx --> |upstream| KMS_2 + Nginx --> |upstream| KMS_3 + KMS_1 --> PG + KMS_2 --> PG + KMS_3 --> PG +``` + +#### Sequence Diagram + +```mermaid +sequenceDiagram + participant Test as test_lb_kms_shutdown.sh + participant Nginx as nginx-load-balancer + participant KMS as kms1 / kms2 / kms3 + + Test->>Nginx: recreate to pick up nginx.conf + Test->>KMS: start postgres, kms1, kms2, kms3 (if not running) + Test->>Nginx: GET /health + Nginx-->>Test: 200 (baseline) + Test->>KMS: docker compose stop kms3 + Test->>Nginx: GET /health + Nginx-->>Test: 200 + Test->>KMS: docker compose stop kms2 + Test->>Nginx: GET /health + Nginx-->>Test: 200 + Test->>KMS: docker compose stop kms1 + Test->>Nginx: GET /health + Nginx-->>Test: 502 or 504 (no backend) + Test->>KMS: start kms1, kms2, kms3 + Test->>Nginx: poll GET /health (up to 20 s) + Nginx-->>Test: 200 (recovered) +``` + +--- + +*End of document.* diff --git a/.mise/tasks/test/spire b/.mise/tasks/test/spire index a1576352a5..51232a680d 100755 --- a/.mise/tasks/test/spire +++ b/.mise/tasks/test/spire @@ -7,10 +7,9 @@ #USAGE choices "static" "dynamic" #USAGE } set -euo pipefail - source "${MISE_CONFIG_ROOT}/.mise/lib/common.sh" source "${MISE_CONFIG_ROOT}/.mise/lib/kms_server.sh" - +source "${MISE_CONFIG_ROOT}/.mise/lib/spire_test.sh" if [ "${usage_variant:-non-fips}" != "non-fips" ]; then print_error "SPIRE tests require non-fips variant (got: ${usage_variant})" fi @@ -31,76 +30,11 @@ SPIRE_SECRETS_ENV="/tmp/spire-secrets.env" # leak across runs and can cause SPIRE login 403s ("invalid role_id or secret_id"). AUTH_DB_FILE="/tmp/auth-verifier-spire-test.db" -# `mise run test:spire` owns host ports 8443 (auth-verifier) and 9998 (KMS). -# A previously crashed or unrelated SPIRE test run can leave stale listeners on -# those ports, causing this task to talk to the wrong processes and produce -# false positives/negatives. Detect and stop any existing listeners up front. -# -# All four helpers use lsof (available on both macOS and Linux) instead of the -# Linux-only ss + GNU awk combination. -listener_pids_for_port() { - local port="$1" - lsof -ti tcp:"${port}" 2>/dev/null | sort -u || true -} - -port_is_listening() { - local port="$1" - lsof -i tcp:"${port}" -sTCP:LISTEN 2>/dev/null | grep -q . 2>/dev/null -} - -wait_for_port_closed() { - local port="$1" timeout_secs="$2" elapsed=0 - while port_is_listening "${port}"; do - if [[ "${elapsed}" -ge "${timeout_secs}" ]]; then - return 1 - fi - sleep 1 - elapsed=$((elapsed + 1)) - done -} - -stop_listener_on_port() { - local port="$1" label="$2" pids pid - pids="$(listener_pids_for_port "${port}")" - [[ -z "${pids}" ]] && return 0 - - print_info "Stopping stale ${label} listener(s) on port ${port}: $(paste -sd, <<<"${pids}")" - while IFS= read -r pid; do - [[ -n "${pid}" ]] && kill "${pid}" 2>/dev/null || true - done <<<"${pids}" - - if wait_for_port_closed "${port}" 10; then - return 0 - fi - - print_info "Port ${port} still busy after SIGTERM; forcing remaining ${label} listener(s) down." - while IFS= read -r pid; do - [[ -n "${pid}" ]] && kill -9 "${pid}" 2>/dev/null || true - done <<<"${pids}" - - wait_for_port_closed "${port}" 5 || - print_error "Port ${port} is still in use after stopping stale ${label} listener(s)." -} - # Remove all leftover SPIRE test state (stale containers/volumes, persistent auth DB, # and temp files) so each run starts from a clean, deterministic environment. Used both # for the up-front reset and by the exit-time cleanup. reset_spire_state() { - # Free the host ports owned by this task before bringing fresh processes up. - stop_listener_on_port 8443 "auth-verifier" - stop_listener_on_port 9998 "KMS" - - # Stop all SPIRE profile containers (including spire-agent) and remove named - # volumes (incl. spire-agent-socket) so `up -d` never reuses a stale container - # carrying outdated AppRole credentials. - docker compose --profile spire down --volumes --remove-orphans 2>/dev/null || true - # Remove the persistent auth-verifier DB (WAL/SHM sidecars included). - rm -f "${AUTH_DB_FILE}" "${AUTH_DB_FILE}-wal" "${AUTH_DB_FILE}-shm" 2>/dev/null || true - # Remove temp files/dirs carrying credentials/tokens from a previous run, - # including the per-tenant patched agent configs and captured server logs. - rm -f "${SPIRE_SECRETS_ENV}" /tmp/spire-join-token.txt 2>/dev/null || true - rm -rf /tmp/spire-agent-config-* 2>/dev/null || true - rm -f /tmp/spire-server-*.log 2>/dev/null || true + spire_reset_state "${AUTH_DB_FILE}" "${SPIRE_SECRETS_ENV}" } # ── Cleanup on exit ─────────────────────────────────────────────────────────── @@ -196,25 +130,13 @@ print_header "SPIRE + Mistral Client Integration Tests" # ── Step 1: Generate TLS certificates if missing ───────────────────────────── print_status "Checking TLS certificates..." -if [[ ! -f "${TEST_DATA}/certs/ca.crt" || ! -f "${TEST_DATA}/certs/jwt.key.pem" ]]; then - print_info "Generating test TLS certificates..." - bash "${TEST_DATA}/certs/generate-test-certs.sh" -else - print_info "TLS certificates already present." -fi +spire_ensure_certs "${TEST_DATA}" # ── Step 2: Build binaries ──────────────────────────────────────────────────── print_status "Building KMS server (non-fips)..." kms_build_server "${FEATURES_FLAG[@]+"${FEATURES_FLAG[@]}"}" -print_status "Building auth-verifier..." -# Note: grep -E changes the exit code; check build exit separately via set -o pipefail. -cargo build --manifest-path "${REPO_ROOT}/authentication/Cargo.toml" \ - --bin auth_verifier - -AUTH_BIN="$(cd "${REPO_ROOT}/authentication" && cargo metadata --no-deps --format-version 1 | - python3 -c "import sys,json; m=json.load(sys.stdin); print(m['target_directory'])")/debug/auth_verifier" - +AUTH_BIN="$(spire_build_auth_verifier "${REPO_ROOT}")" print_status "Building ckms CLI..." kms_build_cli "${FEATURES_FLAG[@]+"${FEATURES_FLAG[@]}"}" @@ -594,7 +516,7 @@ if [[ -n "${AUTH_PID:-}" ]]; then wait "${AUTH_PID}" 2>/dev/null || true AUTH_PID="" fi -if ! wait_for_port_closed 8443 15; then +if ! spire_wait_port_closed 8443 15; then print_error "Scenario 2: auth-verifier port 8443 stayed open after shutdown; stale listener likely present." fi S2_STATUS=$(curl -s -o /dev/null -w "%{http_code}" --cacert "${TEST_DATA}/certs/ca.crt" \ @@ -627,43 +549,48 @@ fi print_header "All SPIRE integration tests passed — KMS+auth-verifier is a valid multi-tenant drop-in replacement for HashiCorp Vault" -# ── Step 16: KMS PKI capability validation via test:spire-pki ──────────────── -# Delegates to the standalone `mise run test:spire-pki` task. -# KMS_SKIP_BUILD=1 skips the build step (already done above). -# The spire-pki task will start its own KMS+auth-verifier instances on the same -# ports. First stop the ones running here so spire-pki gets a clean slate -# (it does its own port cleanup on startup). -print_header "Step 16: KMS PKI capability validation (mise run test:spire-pki)" -print_status "Stopping current KMS and auth-verifier to hand off to spire-pki task..." -[[ -n "${KMS_PID:-}" ]] && kill "${KMS_PID}" 2>/dev/null || true -[[ -n "${AUTH_PID:-}" ]] && kill "${AUTH_PID}" 2>/dev/null || true -wait_for_port_closed 9998 30 || true -wait_for_port_closed 8443 30 || true -KMS_PID="" -AUTH_PID="" - -KMS_SKIP_BUILD=1 mise run test:spire-pki -print_success "Step 16: KMS PKI capability validation passed." - -# ── Step 17: Service mesh SDS delivery (PKI-10) ─────────────────────────────── +# ── Step 16: Service mesh SDS delivery (PKI-10) ─────────────────────────────── # Runs test_sds.sh inline — reuses the already-running spire-server-a and -# spire-agent-a Docker containers. No KMS/auth-verifier restart needed. +# spire-agent-a Docker containers BEFORE stopping KMS/auth-verifier for spire-pki. # Exit code 2 from test_sds.sh means Docker is not available — treat as a # warning (not a failure) so the core SPIRE/KMS tests are not blocked. -print_header "Step 17: Service mesh SDS delivery — PKI-10" +print_header "Step 16: Service mesh SDS delivery — PKI-10" SDS_EXIT=0 SPIRE_SERVER_CONTAINER="spire-server-a" \ TRUST_DOMAIN="$(tenant_trust_domain a)" \ bash "${TEST_DATA}/setup/test_sds.sh" || SDS_EXIT=$? if [[ "${SDS_EXIT}" -eq 0 ]]; then - print_success "Step 17: SDS delivery test passed (PKI-10)." + print_success "Step 16: SDS delivery test passed (PKI-10)." elif [[ "${SDS_EXIT}" -eq 2 ]]; then - print_warning "Step 17: SDS test SKIPPED — Docker not available or SPIRE container stopped." + print_warning "Step 16: SDS test SKIPPED — Docker not available or SPIRE container stopped." print_info "Run 'mise run test:spire-sds' as a standalone test when Docker is ready." else - print_warning "Step 17: SDS delivery test FAILED (exit ${SDS_EXIT}). See output above." + print_warning "Step 16: SDS delivery test FAILED (exit ${SDS_EXIT}). See output above." print_info "Run 'mise run test:spire-sds' to investigate." fi +# ── Step 17: KMS PKI capability validation via test:spire-pki ──────────────── +# Delegates to the standalone `mise run test:spire-pki` task. +# KMS_SKIP_BUILD=1 skips the build step (already done above). +print_header "Step 17: KMS PKI capability validation (mise run test:spire-pki)" +print_status "Stopping current KMS and auth-verifier to hand off to spire-pki task..." +[[ -n "${KMS_PID:-}" ]] && kill "${KMS_PID}" 2>/dev/null || true +[[ -n "${AUTH_PID:-}" ]] && kill "${AUTH_PID}" 2>/dev/null || true +spire_wait_port_closed 9998 30 || true +spire_wait_port_closed 8443 30 || true +KMS_PID="" +AUTH_PID="" + +KMS_SKIP_BUILD=1 mise run test:spire-pki --variant "${usage_variant:-non-fips}" --link "${usage_link:-static}" +print_success "Step 17: KMS PKI capability validation passed." +# ── Step 18: SPIFFE JWT-SVID end-to-end integration (test:spire-jwt-svid) ───── +print_header "Step 18: SPIFFE JWT-SVID authentication (mise run test:spire-jwt-svid)" +mise run test:spire-jwt-svid --variant "${usage_variant:-non-fips}" --link "${usage_link:-static}" +print_success "Step 18: SPIFFE JWT-SVID authentication tests passed." + +# ── Step 19: SPIRE KMIP plugins (test:spire-kmip) ────────────────────────────── +print_header "Step 19: SPIRE KMIP plugins (mise run test:spire-kmip)" +mise run test:spire-kmip --variant "${usage_variant:-non-fips}" --link "${usage_link:-static}" +print_success "Step 19: SPIRE KMIP plugins tests passed." print_success "SPIRE integration tests completed successfully!" diff --git a/.mise/tasks/test/spire-jwt-svid b/.mise/tasks/test/spire-jwt-svid new file mode 100755 index 0000000000..2a83744cd4 --- /dev/null +++ b/.mise/tasks/test/spire-jwt-svid @@ -0,0 +1,714 @@ +#!/usr/bin/env bash +#MISE description="SPIFFE JWT-SVID end-to-end authentication test via SPIRE + ckms CLI" +#USAGE flag "-v --variant " env="VARIANT" help="FIPS variant" default="non-fips" { +#USAGE choices "fips" "non-fips" +#USAGE } +#USAGE flag "-l --link " env="LINK" help="Linkage type" default="static" { +#USAGE choices "static" "dynamic" +#USAGE } +set -euo pipefail +source "${MISE_CONFIG_ROOT}/.mise/lib/common.sh" +source "${MISE_CONFIG_ROOT}/.mise/lib/kms_server.sh" +source "${MISE_CONFIG_ROOT}/.mise/lib/spire_test.sh" +if [ "${usage_variant:-non-fips}" != "non-fips" ]; then + print_error "SPIRE tests require non-fips variant (got: ${usage_variant})" +fi + +kms_init_env "non-fips" "${usage_link:-static}" +setup_test_logging + +REPO_ROOT="$(get_repo_root)" +TEST_DATA="${REPO_ROOT}/test_data/spire" +AUTH_PID="" +AUTH_LOG="/tmp/auth-verifier-spire-jwt-svid.log" +AUTH_DB_FILE="/tmp/auth-verifier-spire-jwt-svid.db" +SPIRE_SECRETS_ENV="/tmp/spire-jwt-svid-secrets.env" +KMS_LOG_FILE="/tmp/kms-spire-jwt-svid.log" +SPIRE_SERVER_CONTAINER="spire-server-a" +SPIRE_SERVER_SOCKET="/tmp/spire-server/private/api.sock" +TRUST_DOMAIN="cosmian-test-a.local" +WORKLOAD_SPIFFE_ID="spiffe://${TRUST_DOMAIN}/test-workload-app" +AUDIENCE="cosmian-kms" +OTHER_AUDIENCE="some-other-service" +JWKS_PORT=8088 +# Strict mode (Linux/CI): real signature/issuer/audience/expiry validation against a JWKS served +# over HTTPS. Signature and audience are ALSO covered by the Rust unit tests +# (middlewares::jwt::jwt_token_auth::real_validation). On macOS native-tls (Security.framework) +# ignores SSL_CERT_FILE, so the KMS cannot trust the test CA: the suite then falls back to an +# `insecure` build (no signature/audience/expiry checks, http JWKS) and skips the negative +# checks that depend on them. +STRICT=1 +if [[ "$(uname -s)" == "Darwin" ]]; then + STRICT=0 +fi +if [[ "${STRICT}" -eq 1 ]]; then + JWKS_URI="https://localhost:${JWKS_PORT}/jwks.json" +else + JWKS_URI="http://localhost:${JWKS_PORT}/jwks.json" +fi +KMS_URL_LOCAL="https://localhost:9998" + +# Everything the suite creates with mktemp lives under WORK_DIR (bearer tokens, cookie jars, +# generated configs, SPIRE agent state, ...) so a single `rm -rf` in cleanup() removes it. +WORK_DIR="" +JWKS_SERVER_PID="" +LOCAL_AGENT_PID="" +KMS_PID="" +UI_DIST_DIR="${REPO_ROOT}/ui/dist" +UI_DIST_REAL=0 +UI_PLACEHOLDER_CREATED=0 +UI_DIST_DIR_CREATED=0 +UI_PLACEHOLDER_MARKER="SPIRE JWT-SVID test placeholder" + +cleanup() { + print_info "Cleaning up SPIRE JWT-SVID test resources..." + [[ -n "${JWKS_SERVER_PID:-}" ]] && kill "${JWKS_SERVER_PID}" 2>/dev/null || true + [[ -n "${LOCAL_AGENT_PID:-}" ]] && kill "${LOCAL_AGENT_PID}" 2>/dev/null || true + [[ -n "${AUTH_PID:-}" ]] && kill "${AUTH_PID}" 2>/dev/null || true + [[ -n "${KMS_PID:-}" ]] && kill "${KMS_PID}" 2>/dev/null || true + # Removes the ckms conf directory created by kms_write_ckms_conf (idempotent). + kms_stop || true + [[ -n "${WORK_DIR:-}" ]] && rm -rf "${WORK_DIR}" || true + # Remove the placeholder SPA index only if this run created it (never a real build). + if [[ "${UI_PLACEHOLDER_CREATED:-0}" == "1" ]]; then + rm -f "${UI_DIST_DIR}/index.html" + [[ "${UI_DIST_DIR_CREATED:-0}" == "1" ]] && rmdir "${UI_DIST_DIR}" 2>/dev/null || true + fi + spire_reset_state "${AUTH_DB_FILE}" "${SPIRE_SECRETS_ENV}" +} +trap cleanup EXIT +require_cmd docker +require_cmd cargo +require_cmd openssl +require_cmd python3 +require_cmd curl + +if [[ "${STRICT}" -eq 0 ]]; then + print_warning "macOS: building the KMS with the 'insecure' feature and serving the JWKS over http. JWT signature/audience/expiry validation is NOT exercised by this run (covered by the Rust unit tests; run this suite on Linux/CI for the full check)." +elif [[ " ${FEATURES_FLAG[*]:-} " == *insecure* ]]; then + # Guard against silently testing a build with all JWT validation disabled. + print_error "The KMS must NOT be built with the 'insecure' feature for this suite (it disables signature/issuer/audience/expiry validation)." +fi + +if [[ ! -d "${TEST_DATA}" ]]; then + print_error "test_data/spire not found." +fi + +WORK_DIR="$(mktemp -d /tmp/spire-jwt-svid-XXXXXX)" + +print_status "Resetting SPIRE test state..." +spire_reset_state "${AUTH_DB_FILE}" "${SPIRE_SECRETS_ENV}" +print_header "SPIRE JWT-SVID Authentication Integration Test" + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +# Start the KMS with SSL_CERT_FILE pointing at the test CA (KMS process only) so that the JWKS +# fetch over HTTPS validates the certificate of the local JWKS server. +# Usage: start_kms (sets KMS_PID) +start_kms() { + local config="$1" log="$2" + SSL_CERT_FILE="${TEST_DATA}/certs/ca.crt" "$(get_kms_bin)" --config "${config}" >"${log}" 2>&1 & + KMS_PID=$! +} + +# Stop the KMS started by start_kms. Idempotent. +stop_kms() { + if [[ -n "${KMS_PID:-}" ]]; then + kill "${KMS_PID}" 2>/dev/null || true + wait "${KMS_PID}" 2>/dev/null || true + KMS_PID="" + fi +} + +# Wait until the KMS listens on 9998, failing fast if the process dies during startup. +# Usage: wait_kms_ready +wait_kms_ready() { + local log="$1" what="$2" deadline=$((SECONDS + 120)) + until wait_for_port 127.0.0.1 9998 1; do + if ! kill -0 "${KMS_PID}" 2>/dev/null; then + tail -n 30 "${log}" >&2 || true + print_error "KMS exited during startup (${what}). Check ${log}" + fi + if [[ "${SECONDS}" -ge "${deadline}" ]]; then + tail -n 30 "${log}" >&2 || true + print_error "KMS did not listen on port 9998 in time (${what}). Check ${log}" + fi + done +} + +# Write a KMS TOML config: base test config + UI folder + [idp_auth]. +# Usage: write_kms_config [mtls] +# A generated file is required because --config takes precedence and ignores CLI args. +write_kms_config() { + local out="$1" spec="$2" svid="$3" mtls="${4:-}" + if [[ -n "${mtls}" ]]; then + sed "/\[tls\]/a\\ +clients_ca_cert_file = \"${TEST_DATA}/certs/ca.crt\" +" "${KMS_CONFIG_FILE}" >"${out}" + else + cat "${KMS_CONFIG_FILE}" >"${out}" + fi + cat >>"${out}" < +write_bearer_conf() { + cat >"$1" < [expected_login_status] +expect_svid_rejected() { + local label="$1" token="$2" login_expected="${3:-401}" + local conf="${WORK_DIR}/ckms-negative.toml" out="${WORK_DIR}/ckms-negative.log" status + write_bearer_conf "${conf}" "${token}" + if "$(get_ckms_bin)" --conf-path "${conf}" --accept-invalid-certs \ + sym keys create "spiffe-negative-key-${RANDOM}" >"${out}" 2>&1; then + print_error "${label}: ckms authenticated with a token that must be rejected. Output: $(cat "${out}")" + fi + print_success "${label}: ckms refused the token ($(tr '\n' ' ' <"${out}" | cut -c1-140))" + status="$(bearer_http_status "${token}")" + [[ "${status}" == "401" ]] || print_error "${label}: bearer request returned HTTP ${status}, expected 401" + print_success "${label}: bearer request returned HTTP 401." + status="$(login_svid_http_status "${token}")" + [[ "${status}" == "${login_expected}" ]] || + print_error "${label}: POST /ui/login_svid returned HTTP ${status}, expected ${login_expected}: $(cat "${WORK_DIR}/login-svid-body")" + print_success "${label}: POST /ui/login_svid returned HTTP ${status}." +} + +# ── Step 1: Check / Generate TLS certificates ─────────────────────────────── +print_status "Step 1: TLS certificates..." +spire_ensure_certs "${TEST_DATA}" + +# ── Step 2: Build server and CLI ───────────────────────────────────────────── +# The server is built WITHOUT the `insecure` feature: real signature, issuer, audience and +# expiry validation, and the https-only JWKS guard, are exactly what this suite must exercise. +print_status "Step 2: Building KMS server (non-fips) & ckms CLI..." +if [[ "${STRICT}" -eq 1 ]]; then + kms_build_server +else + kms_build_server --features non-fips,insecure +fi + +AUTH_BIN="$(spire_build_auth_verifier "${REPO_ROOT}")" +kms_build_cli "${FEATURES_FLAG[@]+"${FEATURES_FLAG[@]}"}" + +# ── Step 3: Start auth-verifier ────────────────────────────────────────────── +print_status "Step 3: Starting auth-verifier on port 8443..." +rm -f "${AUTH_DB_FILE}" +"${AUTH_BIN}" "${TEST_DATA}/config/auth_verifier.toml" >"${AUTH_LOG}" 2>&1 & +AUTH_PID=$! +if ! wait_for_port 127.0.0.1 8443 60; then + print_error "auth-verifier failed to start. Check ${AUTH_LOG}" +fi +print_success "auth-verifier ready." + +# ── Step 4: Bootstrap temporary KMS for Root CA & AppRole provisioning ────── +print_status "Step 4: Bootstrapping temporary KMS server for SPIRE PKI..." +KMS_CONFIG_FILE="${TEST_DATA}/config/kms.toml" +start_kms "${KMS_CONFIG_FILE}" "${KMS_LOG_FILE}" +wait_kms_ready "${KMS_LOG_FILE}" "bootstrap" + +# Not in a command substitution: kms_write_ckms_conf records its temp dir in a global that +# kms_stop (called from cleanup) removes. +kms_write_ckms_conf "${KMS_URL_LOCAL}" >/dev/null +CKMS_CONF="${KMS_CKMS_CONF}" +_VAULT_EXT_FILE="${WORK_DIR}/vault_pki_ca_ext" +cat >"${_VAULT_EXT_FILE}" <<'EXTEOF' +[ v3_ca ] +basicConstraints=critical,CA:TRUE +keyUsage=critical,keyCertSign,crlSign,digitalSignature +EXTEOF + +"$(get_ckms_bin)" --conf-path "${CKMS_CONF}" \ + --accept-invalid-certs \ + certificates certify \ + --generate-key-pair \ + --algorithm nist-p384 \ + --certificate-id vault_pki_ca_cert \ + --subject-name "CN=Cosmian KMS Root CA,O=Cosmian,C=FR" \ + --tag vault_pki_ca \ + --days 3650 \ + --certificate-extensions "${_VAULT_EXT_FILE}" \ + 2>&1 | grep -v "^$" + +AUTH_VERIFIER_URL="https://localhost:8443" \ + VAULT_ADDR="${KMS_URL_LOCAL}" \ + VAULT_CACERT="${TEST_DATA}/certs/ca.crt" \ + CKMS_BIN="$(get_ckms_bin)" \ + CKMS_CONF="${CKMS_CONF}" \ + SECRETS_ENV_FILE="${SPIRE_SECRETS_ENV}" \ + bash "${TEST_DATA}/setup/provision.sh" + +# shellcheck disable=SC1090 +source "${SPIRE_SECRETS_ENV}" + +# ── Step 5: Start SPIRE server (tenant A) ──────────────────────────────────── +print_status "Step 5: Starting SPIRE server A..." +export SPIRE_SERVER_CONFIG_FILE_A="./test_data/spire/config/spire-server-a.conf" +export VAULT_APPROLE_ID_A="${SPIRE_ROLE_ID_A}" +export VAULT_APPROLE_SECRET_ID_A="${SPIRE_SECRET_ID_A}" +docker compose --profile spire up -d "${SPIRE_SERVER_CONTAINER}" + +tries=0 +until [[ "$(docker inspect --format='{{.State.Status}}' "${SPIRE_SERVER_CONTAINER}" 2>/dev/null)" == "running" && "$(docker inspect --format='{{.State.Health.Status}}' "${SPIRE_SERVER_CONTAINER}" 2>/dev/null)" == "healthy" ]]; do + tries=$((tries + 1)) + if [[ "${tries}" -ge 24 ]]; then + docker logs "${SPIRE_SERVER_CONTAINER}" 2>&1 | tail -20 || true + print_error "SPIRE server did not become healthy in time." + fi + sleep 5 +done +print_success "SPIRE server A healthy." + +# ── Step 6: Export the SPIRE trust bundle and serve it as an HTTPS JWKS ────── +print_status "Step 6: Exporting SPIRE bundle and serving the JWKS (${JWKS_URI})..." +JWKS_DIR="${WORK_DIR}/jwks" +mkdir -p "${JWKS_DIR}" +JWKS_FILE="${JWKS_DIR}/jwks.json" + +docker exec "${SPIRE_SERVER_CONTAINER}" \ + /opt/spire/bin/spire-server bundle show \ + -socketPath "${SPIRE_SERVER_SOCKET}" \ + -format SPIFFE >"${JWKS_FILE}" || print_error "spire-server bundle show failed" + +# The SPIFFE bundle lists both x509-svid keys (with x5c, no kid) and jwt-svid keys (kid, +# use=jwt-svid). The KMS JWKS parser (jsonwebtoken::jwk::Jwk) accepts both (`use` is an open +# string and x5c is an optional list), and looks keys up by `kid`, so the bundle is served +# unfiltered, as a real SPIFFE bundle endpoint would. Only assert that a usable key exists. +python3 - "${JWKS_FILE}" <<'PY' || print_error "Exported SPIFFE bundle contains no usable jwt-svid key (kid + use=jwt-svid)" +import json +import sys + +keys = json.load(open(sys.argv[1])).get("keys", []) +jwt_keys = [k for k in keys if k.get("use") == "jwt-svid" and k.get("kid")] +if not jwt_keys: + sys.exit(1) +print(f"SPIFFE bundle: {len(keys)} key(s), {len(jwt_keys)} usable jwt-svid key(s), " + f"kid(s)={[k['kid'] for k in jwt_keys]}") +PY + +cat >"${WORK_DIR}/serve_jwks.py" <<'PY' +import functools +import http.server +import ssl +import sys + +directory, cert, key, port, tls = sys.argv[1], sys.argv[2], sys.argv[3], int(sys.argv[4]), sys.argv[5] == "1" +handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=directory) +httpd = http.server.ThreadingHTTPServer(("127.0.0.1", port), handler) +if tls: + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(cert, key) + httpd.socket = context.wrap_socket(httpd.socket, server_side=True) +httpd.serve_forever() +PY + +python3 "${WORK_DIR}/serve_jwks.py" "${JWKS_DIR}" \ + "${TEST_DATA}/certs/kms.crt" "${TEST_DATA}/certs/kms.key" "${JWKS_PORT}" "${STRICT}" \ + >"${WORK_DIR}/jwks-server.log" 2>&1 & +JWKS_SERVER_PID=$! +if ! wait_for_port 127.0.0.1 "${JWKS_PORT}" 10; then + cat "${WORK_DIR}/jwks-server.log" >&2 || true + print_error "Failed to start JWKS HTTPS server on port ${JWKS_PORT}" +fi +# The certificate must be valid for `localhost` and chain to the test CA. +curl -fsS --cacert "${TEST_DATA}/certs/ca.crt" -o /dev/null "${JWKS_URI}" || + print_error "JWKS endpoint ${JWKS_URI} is not reachable (with certificate validation against test_data/spire/certs/ca.crt in strict mode)" +print_success "JWKS server running: ${JWKS_URI}" + +# ── Step 7: KMS start-up and configuration checks ──────────────────────────── +print_status "Step 7: Restarting KMS with --jwt-svid-auth and the SPIRE JWKS provider..." +stop_kms + +# The KMS only registers the /ui auth routes (login_svid, whoami, ...) when the +# configured UI index folder contains an index.html. CI runners start from a +# clean checkout and never build ui/dist, so provision a placeholder index so +# the BFF endpoints used below are served. A real ui/dist build is never touched; +# the placeholder is removed again by cleanup(). +if [[ -f "${UI_DIST_DIR}/index.html" ]] && ! grep -q "${UI_PLACEHOLDER_MARKER}" "${UI_DIST_DIR}/index.html"; then + UI_DIST_REAL=1 +else + if [[ ! -d "${UI_DIST_DIR}" ]]; then + mkdir -p "${UI_DIST_DIR}" + UI_DIST_DIR_CREATED=1 + fi + echo "${UI_PLACEHOLDER_MARKER}" >"${UI_DIST_DIR}/index.html" + UI_PLACEHOLDER_CREATED=1 +fi + +JWT_PROVIDER_SPEC="https://${TRUST_DOMAIN},${JWKS_URI},${AUDIENCE}" +JWT_PROVIDER_SPEC_NO_AUD="https://${TRUST_DOMAIN},${JWKS_URI}," +KMS_SVID_LOG="/tmp/kms-spire-jwt-svid-server.log" + +# 7a. Negative: --jwt-svid-auth with a provider that has no audience must be refused at start-up. +print_status "Step 7a: KMS must refuse to start with jwt_svid_auth = true and no audience..." +KMS_NOAUD_CONFIG_FILE="${WORK_DIR}/kms-noaud.toml" +KMS_NOAUD_LOG="/tmp/kms-spire-jwt-svid-noaud.log" +write_kms_config "${KMS_NOAUD_CONFIG_FILE}" "${JWT_PROVIDER_SPEC_NO_AUD}" true +start_kms "${KMS_NOAUD_CONFIG_FILE}" "${KMS_NOAUD_LOG}" +deadline=$((SECONDS + 60)) +while kill -0 "${KMS_PID}" 2>/dev/null; do + if [[ "${SECONDS}" -ge "${deadline}" ]]; then + stop_kms + print_error "KMS is still running 60s after start with jwt_svid_auth = true and an empty audience: it must refuse to start. Check ${KMS_NOAUD_LOG}" + fi + sleep 1 +done +noaud_rc=0 +wait "${KMS_PID}" || noaud_rc=$? +KMS_PID="" +if [[ "${noaud_rc}" -eq 0 ]]; then + print_error "KMS exited with status 0 for a jwt_svid_auth provider without audience; expected a non-zero exit. Check ${KMS_NOAUD_LOG}" +fi +if ! grep -qi "audience" "${KMS_NOAUD_LOG}"; then + tail -n 30 "${KMS_NOAUD_LOG}" >&2 || true + print_error "KMS refused to start (exit ${noaud_rc}) but its log does not mention the missing audience. Check ${KMS_NOAUD_LOG}" +fi +print_success "KMS refused to start without an audience (exit ${noaud_rc}): $(grep -i audience "${KMS_NOAUD_LOG}" | head -n1 | cut -c1-200)" + +# 7b. The valid configuration. +KMS_SVID_CONFIG_FILE="${WORK_DIR}/kms-svid.toml" +write_kms_config "${KMS_SVID_CONFIG_FILE}" "${JWT_PROVIDER_SPEC}" true +start_kms "${KMS_SVID_CONFIG_FILE}" "${KMS_SVID_LOG}" +wait_kms_ready "${KMS_SVID_LOG}" "--jwt-svid-auth" +if grep -q "Fetch JWKS" "${KMS_SVID_LOG}"; then + grep "Fetch JWKS" "${KMS_SVID_LOG}" >&2 || true + print_error "The KMS could not fetch the JWKS from ${JWKS_URI} (see log above): TLS trust of the test CA through SSL_CERT_FILE failed." +fi +print_success "KMS server ready with --jwt-svid-auth." + +# ── Step 8: Mint JWT-SVID for workload and configure ckms ───────────────────── +print_status "Step 8: Minting JWT-SVID via spire-server..." +JWT_TOKEN="$(mint_jwt_svid "${WORKLOAD_SPIFFE_ID}" "${AUDIENCE}")" +print_success "JWT-SVID minted successfully for ${WORKLOAD_SPIFFE_ID}." + +print_status "Step 9a: Writing ckms.toml with access_token..." +SVID_CKMS_CONF="${WORK_DIR}/ckms-svid.toml" +write_bearer_conf "${SVID_CKMS_CONF}" "${JWT_TOKEN}" + +print_status "Executing ckms command authenticated by JWT-SVID..." +TEST_KEY_ID="spiffe-test-key-$(date +%s)" +"$(get_ckms_bin)" --conf-path "${SVID_CKMS_CONF}" --accept-invalid-certs \ + sym keys create "${TEST_KEY_ID}" +print_success "Key '${TEST_KEY_ID}' created." + +print_status "Verifying ownership of created object matches SPIFFE ID..." +OWNED_OUTPUT=$("$(get_ckms_bin)" --conf-path "${SVID_CKMS_CONF}" --accept-invalid-certs \ + access-rights owned) +echo "${OWNED_OUTPUT}" +if echo "${OWNED_OUTPUT}" | grep -q "${TEST_KEY_ID}"; then + print_success "Object is owned by the authenticated SPIFFE ID (${WORKLOAD_SPIFFE_ID})!" +else + print_error "Key '${TEST_KEY_ID}' not found in list-owned output: ${OWNED_OUTPUT}" +fi + +# Control for the HTTP probes used by the negative checks below: a valid SVID is NOT a 401. +CONTROL_STATUS="$(bearer_http_status "${JWT_TOKEN}")" +if [[ "${CONTROL_STATUS}" == "401" || "${CONTROL_STATUS}" == "000" ]]; then + print_error "Valid JWT-SVID rejected on ${KMS_URL_LOCAL}/kmip/2_1 (HTTP ${CONTROL_STATUS})" +fi +print_success "Valid JWT-SVID accepted by the KMIP endpoint (HTTP ${CONTROL_STATUS})." + +# ── Step 9c: Negative checks — tokens that must NOT authenticate ───────────── +print_status "Step 9c: Negative checks (wrong audience, tampered signature)..." +if [[ "${STRICT}" -eq 1 ]]; then + WRONG_AUD_TOKEN="$(mint_jwt_svid "${WORKLOAD_SPIFFE_ID}" "${OTHER_AUDIENCE}")" + expect_svid_rejected "SVID minted for audience '${OTHER_AUDIENCE}'" "${WRONG_AUD_TOKEN}" + + TAMPERED_TOKEN="$(tamper_jwt_signature "${JWT_TOKEN}")" + [[ "${TAMPERED_TOKEN}" != "${JWT_TOKEN}" ]] || print_error "Failed to tamper with the JWT signature" + expect_svid_rejected "SVID with tampered signature" "${TAMPERED_TOKEN}" +else + print_info "Skipped on macOS (insecure build does not validate audience/signature); covered by the Rust real_validation unit tests." +fi + +# ── Step 9b: ckms login spire native integration ───────────────────────────── +print_status "Step 9b: Testing ckms login spire directly via local SPIRE Agent..." +LOCAL_SPIRE_AGENT_BIN="$(command -v spire-agent || true)" +if [[ -z "${LOCAL_SPIRE_AGENT_BIN}" && -x "${HOME}/go/bin/spire-agent" ]]; then + LOCAL_SPIRE_AGENT_BIN="${HOME}/go/bin/spire-agent" +fi + +if [[ -n "${LOCAL_SPIRE_AGENT_BIN}" && "${STRICT}" -eq 1 ]]; then + AGENT_DIR="${WORK_DIR}/spire-agent" + AGENT_CONF_FILE="${WORK_DIR}/spire-agent.conf" + AGENT_LOG="${WORK_DIR}/spire-agent.log" + LOGIN_SPIRE_CONF="${WORK_DIR}/ckms-login-spire.toml" + LOGIN_LOG="${WORK_DIR}/ckms-login-spire.log" + mkdir -p "${AGENT_DIR}/data" + + # Generate join token from spire-server-a + GEN_TOKEN_OUT=$(docker exec "${SPIRE_SERVER_CONTAINER}" \ + /opt/spire/bin/spire-server token generate \ + -socketPath "${SPIRE_SERVER_SOCKET}" \ + -spiffeID "spiffe://${TRUST_DOMAIN}/test-local-agent" \ + -ttl 300) || print_error "spire-server token generate failed" + AGENT_JOIN_TOKEN=$(echo "${GEN_TOKEN_OUT}" | awk '/^Token:/{print $2}' | tr -d '\r\n') + if [[ -z "${AGENT_JOIN_TOKEN}" ]]; then + print_error "Failed to extract the SPIRE agent join token. Output: ${GEN_TOKEN_OUT}" + fi + + # Register workload entry for local ckms process under this agent + ENTRY_OUT=$(docker exec "${SPIRE_SERVER_CONTAINER}" \ + /opt/spire/bin/spire-server entry create \ + -socketPath "${SPIRE_SERVER_SOCKET}" \ + -spiffeID "${WORKLOAD_SPIFFE_ID}" \ + -parentID "spiffe://${TRUST_DOMAIN}/test-local-agent" \ + -selector "unix:uid:$(id -u)" 2>&1) || + print_error "spire-server entry create failed: ${ENTRY_OUT}" + + cat >"${AGENT_CONF_FILE}" <"${AGENT_LOG}" 2>&1 & + LOCAL_AGENT_PID=$! + + # Wait for agent socket + for _ in $(seq 1 30); do + [[ -S "${AGENT_DIR}/api.sock" ]] && break + sleep 0.5 + done + if [[ ! -S "${AGENT_DIR}/api.sock" ]]; then + tail -n 30 "${AGENT_LOG}" >&2 || true + print_error "SPIRE agent did not create its Workload API socket ${AGENT_DIR}/api.sock" + fi + + # Wait for cache sync + agent_synced=0 + for _ in $(seq 1 20); do + if "${LOCAL_SPIRE_AGENT_BIN}" api fetch jwt -socketPath "${AGENT_DIR}/api.sock" -audience "${AUDIENCE}" >/dev/null 2>&1; then + agent_synced=1 + break + fi + sleep 0.5 + done + if [[ "${agent_synced}" -ne 1 ]]; then + tail -n 30 "${AGENT_LOG}" >&2 || true + print_error "SPIRE agent never issued a JWT-SVID for the local workload (registration entry not synced?)" + fi + + cat >"${LOGIN_SPIRE_CONF}" <"${LOGIN_LOG}" 2>&1; then + cat "${LOGIN_LOG}" >&2 || true + print_error "ckms login spire failed although a spire-agent is available and synced." + fi + TEST_KEY_SPIRE="spiffe-login-spire-key-$(date +%s)" + "$(get_ckms_bin)" --conf-path "${LOGIN_SPIRE_CONF}" sym keys create "${TEST_KEY_SPIRE}" + print_success "ckms login spire successfully fetched JWT-SVID from local agent and created key '${TEST_KEY_SPIRE}'!" + + kill "${LOCAL_AGENT_PID}" 2>/dev/null || true + wait "${LOCAL_AGENT_PID}" 2>/dev/null || true + LOCAL_AGENT_PID="" +else + # macOS: the spire-server container uses network_mode: host under Docker Desktop, so its + # attestation port 8081 is not reachable from a native agent process. + print_info "No native spire-agent (or macOS Docker Desktop host networking): verifying ckms login spire CLI flag validation only..." + "$(get_ckms_bin)" login spire --help >/dev/null + print_success "ckms login spire CLI help flag verified." +fi + +# ── Step 10: Run Playwright E2E tests with the JWT-SVID ────────────────────── +print_status "Step 10: Running Playwright E2E tests with JWT-SVID..." +export PLAYWRIGHT_KMS_URL="https://127.0.0.1:9998" +export TEST_JWT_SVID_TOKEN="${JWT_TOKEN}" +export TEST_SPIFFE_ID="${WORKLOAD_SPIFFE_ID}" +if [[ "${STRICT}" -eq 1 ]]; then + export TEST_JWT_STRICT=1 +fi + +# The UI lockfile requires pnpm >= 10, which is not pre-installed on CI runners +# used by the spire job. Bootstrap it from npm when it is missing or too old. +ensure_pnpm_v10 +if ! command -v pnpm >/dev/null 2>&1; then + print_error "pnpm is required for the Playwright E2E tests (Step 10) but could not be provisioned." +fi + +# spiffe-jwt-svid-auth.spec.ts exercises the JSON /ui endpoints only. spiffe-ui-login.spec.ts +# establishes a session through POST /ui/login_svid and then loads the SPA, which a placeholder +# index.html cannot render: run it only when a real ui/dist build exists. +E2E_SPECS=(spiffe-jwt-svid-auth.spec.ts) +if [[ "${UI_DIST_REAL}" -eq 1 ]]; then + E2E_SPECS+=(spiffe-ui-login.spec.ts) +else + print_info "No real ui/dist build found (placeholder index.html only): skipping spiffe-ui-login.spec.ts." +fi + +if (cd "${REPO_ROOT}/ui" && ([ -d node_modules ] || pnpm install --frozen-lockfile) && CI=true pnpm run test:e2e -- "${E2E_SPECS[@]}"); then + print_success "Playwright E2E tests for SPIFFE JWT-SVID passed (${E2E_SPECS[*]})." +else + print_error "Playwright E2E tests for SPIFFE JWT-SVID failed (${E2E_SPECS[*]})." +fi + +# ── Step 11: Mint a demo-user JWT-SVID and log into the Web UI with it ──────── +print_status "Step 11: Testing Web UI /ui/login_svid and session verification..." +DEMO_USER_SPIFFE_ID="spiffe://${TRUST_DOMAIN}/webui-demo-user" +DEMO_JWT_TOKEN="$(mint_jwt_svid "${DEMO_USER_SPIFFE_ID}" "${AUDIENCE}")" +print_success "Demo-user JWT-SVID minted for ${DEMO_USER_SPIFFE_ID}." + +# Test POST /ui/login_svid endpoint directly (transparent gateway BFF flow) +COOKIEJAR="${WORK_DIR}/ui-cookies" +LOGIN_STATUS="$(login_svid_http_status "${DEMO_JWT_TOKEN}" "${COOKIEJAR}")" +LOGIN_RESP="$(cat "${WORK_DIR}/login-svid-body")" +if [[ "${LOGIN_STATUS}" == "200" ]] && echo "${LOGIN_RESP}" | grep -q "Authenticated"; then + print_success "POST /ui/login_svid returned 200 Authenticated." +else + print_error "POST /ui/login_svid failed (HTTP ${LOGIN_STATUS}): ${LOGIN_RESP}" +fi + +WHOAMI_RESP=$(curl -k -s -b "${COOKIEJAR}" "${KMS_URL_LOCAL}/ui/whoami") +if echo "${WHOAMI_RESP}" | grep -q "${DEMO_USER_SPIFFE_ID}"; then + print_success "GET /ui/whoami returned expected SPIFFE ID: ${WHOAMI_RESP}" +else + print_error "GET /ui/whoami failed to return expected SPIFFE ID. Got: ${WHOAMI_RESP}" +fi + +# ── Step 12: A KMS started WITHOUT jwt_svid_auth must reject a valid SVID ───── +print_status "Step 12: Restarting KMS WITHOUT jwt_svid_auth; a valid JWT-SVID must be rejected..." +stop_kms +KMS_NOSVID_CONFIG_FILE="${WORK_DIR}/kms-nosvid.toml" +KMS_NOSVID_LOG="/tmp/kms-spire-jwt-svid-disabled.log" +write_kms_config "${KMS_NOSVID_CONFIG_FILE}" "${JWT_PROVIDER_SPEC}" false +start_kms "${KMS_NOSVID_CONFIG_FILE}" "${KMS_NOSVID_LOG}" +wait_kms_ready "${KMS_NOSVID_LOG}" "jwt_svid_auth = false" + +# /ui/login_svid answers 500 (not 401) when --jwt-svid-auth is not enabled. +expect_svid_rejected "Valid SVID with jwt_svid_auth disabled" "${JWT_TOKEN}" 500 +print_success "A KMS without jwt_svid_auth refuses JWT-SVIDs." + +# ── Step 13: Validate mTLS + JWT-SVID simultaneously ─────────────────────────── +print_status "Step 13: Validating mTLS + JWT-SVID configured together..." +stop_kms + +KMS_MTLS_JWT_LOG="/tmp/kms-spire-mtls-jwt-svid.log" +KMS_DUAL_CONFIG_FILE="${WORK_DIR}/kms-dual.toml" +write_kms_config "${KMS_DUAL_CONFIG_FILE}" "${JWT_PROVIDER_SPEC}" true mtls +start_kms "${KMS_DUAL_CONFIG_FILE}" "${KMS_MTLS_JWT_LOG}" +wait_kms_ready "${KMS_MTLS_JWT_LOG}" "mTLS + --jwt-svid-auth" +print_success "KMS server ready with both mTLS and --jwt-svid-auth." + +# 1. Test request using JWT-SVID bearer token (no client cert) +TEST_KEY_ID_JWT="spiffe-dual-jwt-key-$(date +%s)" +"$(get_ckms_bin)" --conf-path "${SVID_CKMS_CONF}" --accept-invalid-certs \ + sym keys create "${TEST_KEY_ID_JWT}" +print_success "Key '${TEST_KEY_ID_JWT}' created via JWT-SVID on dual-auth server." + +OWNED_OUTPUT_JWT=$("$(get_ckms_bin)" --conf-path "${SVID_CKMS_CONF}" --accept-invalid-certs \ + access-rights owned) +if echo "${OWNED_OUTPUT_JWT}" | grep -q "${TEST_KEY_ID_JWT}"; then + print_success "Dual auth (JWT-SVID path): Object is owned by ${WORKLOAD_SPIFFE_ID}!" +else + print_error "Key '${TEST_KEY_ID_JWT}' not found in list-owned output: ${OWNED_OUTPUT_JWT}" +fi + +# 2. Test request using mTLS client certificate (no JWT bearer token) +MTLS_CKMS_CONF="${WORK_DIR}/ckms-mtls.toml" +cat >"${MTLS_CKMS_CONF}" </dev/null | sort -u || true; } -port_is_listening() { lsof -i tcp:"$1" -sTCP:LISTEN 2>/dev/null | grep -q . 2>/dev/null; } - -wait_for_port_closed() { - local port="$1" timeout_secs="$2" elapsed=0 - while port_is_listening "${port}"; do - [[ "${elapsed}" -ge "${timeout_secs}" ]] && return 1 - sleep 1 - elapsed=$((elapsed + 1)) - done -} - -stop_listener_on_port() { - local port="$1" label="$2" pids pid - pids="$(listener_pids_for_port "${port}")" - [[ -z "${pids}" ]] && return 0 - print_info "Stopping stale ${label} listener(s) on port ${port}" - while IFS= read -r pid; do - [[ -n "${pid}" ]] && kill "${pid}" 2>/dev/null || true - done <<<"${pids}" - wait_for_port_closed "${port}" 10 || { - while IFS= read -r pid; do - [[ -n "${pid}" ]] && kill -9 "${pid}" 2>/dev/null || true - done <<<"${pids}" - wait_for_port_closed "${port}" 5 || print_error "Port ${port} still in use." - } -} - # ── Cleanup ─────────────────────────────────────────────────────────────────── SPIRE_PID="" _TMP_DIR="" @@ -83,7 +55,7 @@ cleanup() { kms_stop [[ -n "${_TMP_DIR}" ]] && rm -rf "${_TMP_DIR}" 2>/dev/null || true for p in ${KMS_HTTPS_PORT} ${KMS_KMIP_PORT} ${SPIRE_BIND_PORT} ${SPIRE_HEALTH_PORT}; do - stop_listener_on_port "${p}" "spire-kmip-km" 2>/dev/null || true + spire_stop_port "${p}" "spire-kmip-km" 2>/dev/null || true done } trap cleanup EXIT @@ -97,7 +69,7 @@ require_cmd openssl # ── Clean state ─────────────────────────────────────────────────────────────── print_status "Resetting spire-kmip-key-manager test state..." for p in ${KMS_HTTPS_PORT} ${KMS_KMIP_PORT} ${SPIRE_BIND_PORT} ${SPIRE_HEALTH_PORT}; do - stop_listener_on_port "${p}" "spire-kmip-km" 2>/dev/null || true + spire_stop_port "${p}" "spire-kmip-km" 2>/dev/null || true done print_header "SPIRE kmip KeyManager Plugin E2E — Binary KMIP 2.1 TCP/TLS" diff --git a/.mise/tasks/test/spire-kmip-upstream-authority b/.mise/tasks/test/spire-kmip-upstream-authority index 968d7cf3b4..ab8aa87601 100755 --- a/.mise/tasks/test/spire-kmip-upstream-authority +++ b/.mise/tasks/test/spire-kmip-upstream-authority @@ -29,7 +29,7 @@ set -euo pipefail source "${MISE_CONFIG_ROOT}/.mise/lib/common.sh" source "${MISE_CONFIG_ROOT}/.mise/lib/kms_server.sh" source "${MISE_CONFIG_ROOT}/.mise/lib/kms_build.sh" - +source "${MISE_CONFIG_ROOT}/.mise/lib/spire_test.sh" if [ "${usage_variant:-non-fips}" != "non-fips" ]; then print_error "spire-kmip-upstream-authority tests require non-fips variant (got: ${usage_variant})" fi @@ -48,35 +48,6 @@ KMS_KMIP_PORT=5697 SPIRE_BIND_PORT=18082 SPIRE_HEALTH_PORT=19081 -# ── Helpers ─────────────────────────────────────────────────────────────────── -listener_pids_for_port() { lsof -ti tcp:"$1" 2>/dev/null | sort -u || true; } -port_is_listening() { lsof -i tcp:"$1" -sTCP:LISTEN 2>/dev/null | grep -q . 2>/dev/null; } - -wait_for_port_closed() { - local port="$1" timeout_secs="$2" elapsed=0 - while port_is_listening "${port}"; do - [[ "${elapsed}" -ge "${timeout_secs}" ]] && return 1 - sleep 1 - elapsed=$((elapsed + 1)) - done -} - -stop_listener_on_port() { - local port="$1" label="$2" pids pid - pids="$(listener_pids_for_port "${port}")" - [[ -z "${pids}" ]] && return 0 - print_info "Stopping stale ${label} listener(s) on port ${port}" - while IFS= read -r pid; do - [[ -n "${pid}" ]] && kill "${pid}" 2>/dev/null || true - done <<<"${pids}" - wait_for_port_closed "${port}" 10 || { - while IFS= read -r pid; do - [[ -n "${pid}" ]] && kill -9 "${pid}" 2>/dev/null || true - done <<<"${pids}" - wait_for_port_closed "${port}" 5 || print_error "Port ${port} still in use." - } -} - # ── Cleanup ─────────────────────────────────────────────────────────────────── SPIRE_PID="" _TMP_DIR="" @@ -87,7 +58,7 @@ cleanup() { kms_stop [[ -n "${_TMP_DIR}" ]] && rm -rf "${_TMP_DIR}" 2>/dev/null || true for p in ${KMS_HTTPS_PORT} ${KMS_KMIP_PORT} ${SPIRE_BIND_PORT} ${SPIRE_HEALTH_PORT}; do - stop_listener_on_port "${p}" "spire-kmip-ua" 2>/dev/null || true + spire_stop_port "${p}" "spire-kmip-ua" 2>/dev/null || true done } trap cleanup EXIT @@ -101,7 +72,7 @@ require_cmd openssl # ── Clean state ─────────────────────────────────────────────────────────────── print_status "Resetting spire-kmip-upstream-authority test state..." for p in ${KMS_HTTPS_PORT} ${KMS_KMIP_PORT} ${SPIRE_BIND_PORT} ${SPIRE_HEALTH_PORT}; do - stop_listener_on_port "${p}" "spire-kmip-ua" 2>/dev/null || true + spire_stop_port "${p}" "spire-kmip-ua" 2>/dev/null || true done print_header "SPIRE kmip UpstreamAuthority Plugin E2E — Binary KMIP 2.1 TCP/TLS" diff --git a/.mise/tasks/test/spire-pki b/.mise/tasks/test/spire-pki index 77be97daa1..f7305e7332 100755 --- a/.mise/tasks/test/spire-pki +++ b/.mise/tasks/test/spire-pki @@ -34,7 +34,7 @@ set -euo pipefail source "${MISE_CONFIG_ROOT}/.mise/lib/common.sh" source "${MISE_CONFIG_ROOT}/.mise/lib/kms_server.sh" - +source "${MISE_CONFIG_ROOT}/.mise/lib/spire_test.sh" if [ "${usage_variant:-non-fips}" != "non-fips" ]; then print_error "spire-pki tests require non-fips variant (Vault API is non-FIPS only)" fi @@ -50,39 +50,6 @@ AUTH_DB_FILE="/tmp/auth-verifier-spire-pki.db" SPIRE_SECRETS_ENV="/tmp/spire-pki-secrets.env" KMS_LOG_FILE="/tmp/kms-spire-pki.log" -# ── Port cleanup helpers (same pattern as spire task) ───────────────────────── -listener_pids_for_port() { - lsof -ti tcp:"$1" 2>/dev/null | sort -u || true -} - -port_is_listening() { - lsof -i tcp:"$1" -sTCP:LISTEN 2>/dev/null | grep -q . 2>/dev/null -} - -wait_for_port_closed() { - local port="$1" timeout_secs="$2" elapsed=0 - while port_is_listening "${port}"; do - [[ "${elapsed}" -ge "${timeout_secs}" ]] && return 1 - sleep 1 - elapsed=$((elapsed + 1)) - done -} - -stop_listener_on_port() { - local port="$1" label="$2" pids pid - pids="$(listener_pids_for_port "${port}")" - [[ -z "${pids}" ]] && return 0 - print_info "Stopping stale ${label} listener on port ${port}" - while IFS= read -r pid; do - [[ -n "${pid}" ]] && kill "${pid}" 2>/dev/null || true - done <<<"${pids}" - wait_for_port_closed "${port}" 10 || { - while IFS= read -r pid; do - [[ -n "${pid}" ]] && kill -9 "${pid}" 2>/dev/null || true - done <<<"${pids}" - } -} - # ── Cleanup on exit ──────────────────────────────────────────────────────────── cleanup() { [[ -n "${AUTH_PID}" ]] && kill "${AUTH_PID}" 2>/dev/null || true @@ -98,19 +65,7 @@ require_cmd cargo print_status "Building KMS server (non-fips)..." kms_build_server "${FEATURES_FLAG[@]+"${FEATURES_FLAG[@]}"}" -print_status "Building auth-verifier..." -cargo build --manifest-path "${REPO_ROOT}/authentication/Cargo.toml" \ - --bin auth_verifier - -AUTH_BIN="$( - cd "${REPO_ROOT}/authentication" && - cargo metadata --no-deps --format-version 1 | - python3 -c "import sys,json; m=json.load(sys.stdin); print(m['target_directory'])" -)/debug/auth_verifier" -if [[ -z "${AUTH_BIN}" || ! -x "${AUTH_BIN}" ]]; then - print_error "auth-verifier binary not found after build." -fi - +AUTH_BIN="$(spire_build_auth_verifier "${REPO_ROOT}")" print_status "Building ckms CLI..." kms_build_cli "${FEATURES_FLAG[@]+"${FEATURES_FLAG[@]}"}" @@ -118,15 +73,12 @@ print_success "Binaries built." # ── Step 2: Generate TLS certs if missing ────────────────────────────────────── print_header "Step 2: TLS certificates" -if [[ ! -f "${TEST_DATA}/certs/ca.crt" ]]; then - print_status "Generating test TLS certs..." - bash "${TEST_DATA}/certs/generate-test-certs.sh" -fi +spire_ensure_certs "${TEST_DATA}" print_success "TLS certs ready." # ── Step 3: Stop stale listeners ─────────────────────────────────────────────── -stop_listener_on_port 8443 "auth-verifier" -stop_listener_on_port 9998 "KMS" +spire_stop_port 8443 "auth-verifier" +spire_stop_port 9998 "KMS" # ── Step 4: Start auth-verifier ──────────────────────────────────────────────── print_header "Step 4: auth-verifier" diff --git a/.mise/tasks/test/spire-sds b/.mise/tasks/test/spire-sds index f7af5c94c3..cb7fd90f5c 100755 --- a/.mise/tasks/test/spire-sds +++ b/.mise/tasks/test/spire-sds @@ -29,7 +29,7 @@ set -euo pipefail source "${MISE_CONFIG_ROOT}/.mise/lib/common.sh" source "${MISE_CONFIG_ROOT}/.mise/lib/kms_server.sh" - +source "${MISE_CONFIG_ROOT}/.mise/lib/spire_test.sh" if [ "${usage_variant:-non-fips}" != "non-fips" ]; then print_error "spire-sds tests require non-fips variant (Vault API is non-FIPS only)" fi @@ -47,28 +47,6 @@ KMS_LOG_FILE="/tmp/kms-spire-sds.log" SPIRE_SERVER_CONTAINER="spire-server-a" TRUST_DOMAIN="cosmian-test-a.local" -# ── Port cleanup helpers ─────────────────────────────────────────────────────── -listener_pids_for_port() { lsof -ti tcp:"$1" 2>/dev/null | sort -u || true; } -port_is_listening() { lsof -i tcp:"$1" -sTCP:LISTEN 2>/dev/null | grep -q . 2>/dev/null; } -wait_for_port_closed() { - local port="$1" timeout_secs="$2" elapsed=0 - while port_is_listening "${port}"; do - [[ "${elapsed}" -ge "${timeout_secs}" ]] && return 1 - sleep 1 - elapsed=$((elapsed + 1)) - done -} -stop_listener_on_port() { - local port="$1" label="$2" pids pid - pids="$(listener_pids_for_port "${port}")" - [[ -z "${pids}" ]] && return 0 - print_info "Stopping stale ${label} listener on port ${port}" - while IFS= read -r pid; do [[ -n "${pid}" ]] && kill "${pid}" 2>/dev/null || true; done <<<"${pids}" - wait_for_port_closed "${port}" 10 || { - while IFS= read -r pid; do [[ -n "${pid}" ]] && kill -9 "${pid}" 2>/dev/null || true; done <<<"${pids}" - } -} - # ── Cleanup on exit ──────────────────────────────────────────────────────────── cleanup() { [[ -n "${AUTH_PID}" ]] && kill "${AUTH_PID}" 2>/dev/null || true @@ -94,28 +72,18 @@ require_cmd cargo print_status "Building KMS server (non-fips)..." kms_build_server "${FEATURES_FLAG[@]+"${FEATURES_FLAG[@]}"}" -print_status "Building auth-verifier..." -cargo build --manifest-path "${REPO_ROOT}/authentication/Cargo.toml" --bin auth_verifier - -AUTH_BIN="$( - cd "${REPO_ROOT}/authentication" && - cargo metadata --no-deps --format-version 1 | - python3 -c "import sys,json; m=json.load(sys.stdin); print(m['target_directory'])" -)/debug/auth_verifier" -[[ -z "${AUTH_BIN}" || ! -x "${AUTH_BIN}" ]] && print_error "auth-verifier binary not found." - +AUTH_BIN="$(spire_build_auth_verifier "${REPO_ROOT}")" print_status "Building ckms CLI..." kms_build_cli "${FEATURES_FLAG[@]+"${FEATURES_FLAG[@]}"}" print_success "Binaries built." # ── Step 2: TLS certs ───────────────────────────────────────────────────────── print_header "Step 2: TLS certificates" -[[ ! -f "${TEST_DATA}/certs/ca.crt" ]] && bash "${TEST_DATA}/certs/generate-test-certs.sh" -print_success "TLS certs ready." +spire_ensure_certs "${TEST_DATA}" # ── Step 3: Stop stale listeners ────────────────────────────────────────────── -stop_listener_on_port 8443 "auth-verifier" -stop_listener_on_port 9998 "KMS" +spire_stop_port 8443 "auth-verifier" +spire_stop_port 9998 "KMS" # ── Step 4: Start auth-verifier ──────────────────────────────────────────────── print_header "Step 4: auth-verifier" diff --git a/.mise/tasks/test/wasm b/.mise/tasks/test/wasm index 0224dbe200..036244833a 100755 --- a/.mise/tasks/test/wasm +++ b/.mise/tasks/test/wasm @@ -80,26 +80,6 @@ if [ -f "${REPO_ROOT}/ui/src/wasm/pkg/package.json" ]; then " fi -# Ensure pnpm >= 10 is available. -# The nix-shell provides pnpm 9.x, but the lockfile (lockfileVersion 9.0) requires pnpm 10+. -# Install pnpm 10.17.1 from npm into a temp dir and prepend to PATH. -ensure_pnpm_v10() { - local pnpm_major - pnpm_major=$(pnpm --version 2>/dev/null | cut -d. -f1 || echo "0") - if [ "${pnpm_major}" -ge 10 ]; then - return 0 - fi - if command -v npm >/dev/null 2>&1; then - local _pnpm_tmp - _pnpm_tmp="$(mktemp -d)" - npm install "pnpm@10.17.1" --prefix "${_pnpm_tmp}" --no-save --quiet >/dev/null 2>&1 || true - if [ -f "${_pnpm_tmp}/node_modules/.bin/pnpm" ]; then - export PATH="${_pnpm_tmp}/node_modules/.bin:${PATH}" - echo "Upgraded to pnpm $(pnpm --version)" - fi - fi -} - # 5. Run UI TypeScript check + unit tests (pnpm is in ui/, not in wasm crate) echo "Running UI unit tests..." ( diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6e6456b629..2cda32b9ed 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -54,7 +54,7 @@ repos: rev: v1.31.1 hooks: - id: typos - exclude: documentation/docs/images/google_cse.drawio.svg|crate/test_server/src/test_jwt.rs|crate/pkcs11/documentation/veracrypt_ckms.svg|crate/server/src/tests/google_cse/|documentation/docs/pkcs11/images|documentation/docs/kms_clients/pkcs11/images|crate/server/resources|documentation/docs/algorithms.md|crate/server/src/tests/certificates/chain/root/ca/|documentation/docs/pki/smime.md|documentation/docs/hsms/proteccio.md|crate/crypto/src/crypto/rsa/ckm_rsa_aes_key_wrap.rs|crate/clients/ckms/src/tests/shared/export_import.rs|ui/src/Locate.tsx|kmip/|.mise/scripts/oracle/README_HSM.md|crate/pkcs11/documentation/veracrypt_ckms.svg|documentation/docs/pkcs11/images|nix/signing-keys/cosmian-kms-public.asc|sbom/|documentation/docs/certifications_and_compliance/cryptographic_algorithms/benchmarks/|crate/clients/clap/src/tests/shared/export_import.rs|crate/crypto/src/crypto/fpe/ff1.rs|documentation/docs/benchmarks/|.mise/scripts/bench/bench_run_flamegraph.sh|docs.instructions.md|crate/server/src/tests/jose/rfc_vectors.rs|ui/src/i18n/locales/fr/ + exclude: ui/src/i18n/locales/fr/|ui/src/i18n/locales/zh-CN/|documentation/docs/images/google_cse.drawio.svg|crate/test_server/src/test_jwt.rs|crate/pkcs11/documentation/veracrypt_ckms.svg|crate/server/src/tests/google_cse/|documentation/docs/pkcs11/images|documentation/docs/kms_clients/pkcs11/images|crate/server/resources|documentation/docs/algorithms.md|crate/server/src/tests/certificates/chain/root/ca/|documentation/docs/pki/smime.md|documentation/docs/hsms/proteccio.md|crate/crypto/src/crypto/rsa/ckm_rsa_aes_key_wrap.rs|crate/clients/ckms/src/tests/shared/export_import.rs|ui/src/Locate.tsx|kmip/|.mise/scripts/oracle/README_HSM.md|crate/pkcs11/documentation/veracrypt_ckms.svg|documentation/docs/pkcs11/images|nix/signing-keys/cosmian-kms-public.asc|sbom/|documentation/docs/certifications_and_compliance/cryptographic_algorithms/benchmarks/|crate/clients/clap/src/tests/shared/export_import.rs|crate/crypto/src/crypto/fpe/ff1.rs|documentation/docs/benchmarks/|.mise/scripts/bench/bench_run_flamegraph.sh|docs.instructions.md|crate/server/src/tests/jose/rfc_vectors.rs # ── Whitespace / line-endings ───────────────────────────────────────── - repo: https://github.com/Lucas-C/pre-commit-hooks diff --git a/CHANGELOG/support_JWT-SVID_auth.md b/CHANGELOG/support_JWT-SVID_auth.md new file mode 100644 index 0000000000..f9f6c27dfd --- /dev/null +++ b/CHANGELOG/support_JWT-SVID_auth.md @@ -0,0 +1,28 @@ +## Features + +### Server / Config + +- Add opt-in SPIFFE JWT-SVID workload authentication: `--jwt-svid-auth` / `KMS_JWT_SVID_AUTH` / `[idp_auth] jwt_svid_auth = true` (default `false`, global to all `--jwt-auth-provider` entries). A validated JWT without an `email` claim is accepted when `sub` starts with `spiffe://`; the full SPIFFE ID becomes the KMS user and object owner (audit method `JwtSvid`). +- **Audience required**: with `--jwt-svid-auth`, the server refuses to start if any `--jwt-auth-provider` entry has no audience (`issuer,jwks_uri,audience`), and SPIFFE subjects are rejected when the token has no non-empty `aud` claim. Google CSE issuers never accept SPIFFE subjects. +- When client-certificate authentication (`clients_ca_cert_file`) and `--jwt-svid-auth` are both enabled, a client certificate with a CN is authenticated first and takes precedence over any JWT-SVID or session cookie. +- `kms setup` auth wizard now asks whether the configured JWT/OIDC providers issue SPIFFE JWT-SVIDs. + +### CLI + +- Add `ckms login spire --audience [--spiffe-id ] [--socket-path ]`: fetches a JWT-SVID from the local SPIRE Agent Workload API and stores it as `http_config.access_token`. `--socket-path` accepts a bare absolute path or a `unix://` / `tcp://` URI; when omitted, `SPIFFE_ENDPOINT_SOCKET` is used. + +### API + +- Add `POST /ui/login_svid` (body `{"jwt_svid":""}`): a gateway/BFF establishes a cookie-backed Web UI session from a JWT-SVID. Returns 200 `{"next_step":"Authenticated"}`, 401 for an invalid SVID (only `spiffe://` subjects are accepted; the JWKS is refreshed once and validation retried on failure), and 500 when `--jwt-svid-auth` is not enabled or the session cannot be stored. + +### UI + +- `GET /ui/auth_method` advertises `SPIFFE` in `auth_methods` when `--jwt-svid-auth` is set (priority JWT > SPIFFE > AUTH_VERIFIER > CERT). Browsers without a session see an informational notice; sessions are established by a gateway, not by a login form. + +## Testing + +- Add the `.mise/tasks/test/spire-jwt-svid` end-to-end suite: mint a JWT-SVID against a live SPIRE server, configure `ckms`, create an object and verify it is owned by the SPIFFE ID. + +## Documentation + +- Add ADR-2026-09-19 (SPIFFE JWT-SVID authentication) and the SPIFFE guides for the CLI, the Web UI gateway/BFF flow and workload authentication, including the mTLS precedence and audience requirements. diff --git a/Cargo.lock b/Cargo.lock index 83e65df3d9..b7627a4f1c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1049,7 +1049,7 @@ dependencies = [ "url", "uuid", "winresource", - "x509-parser", + "x509-parser 0.17.0", ] [[package]] @@ -1459,6 +1459,7 @@ dependencies = [ "serde_json", "serial_test", "sha2 0.10.9", + "spiffe", "strum 0.27.2", "tempfile", "test_kms_server", @@ -1579,7 +1580,7 @@ dependencies = [ "time", "tokio", "uuid", - "x509-parser", + "x509-parser 0.17.0", "zeroize", ] @@ -1741,7 +1742,7 @@ dependencies = [ "uuid", "windows-service", "winresource", - "x509-parser", + "x509-parser 0.17.0", "zeroize", ] @@ -1777,7 +1778,7 @@ dependencies = [ "tokio-rusqlite", "url", "uuid", - "x509-parser", + "x509-parser 0.17.0", "zeroize", ] @@ -6148,6 +6149,34 @@ dependencies = [ "pkcs11-sys", ] +[[package]] +name = "spiffe" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d0a770f9a8571eb96f9c319b3b55dd7118e3047bd3629b894882506b9e8295" +dependencies = [ + "arc-swap", + "base64ct", + "fastrand", + "futures", + "hyper-util", + "pkcs8", + "prost 0.14.4", + "prost-types", + "serde", + "serde_json", + "thiserror 2.0.19", + "time", + "tokio", + "tokio-util", + "tonic 0.14.6", + "tonic-prost", + "tower 0.5.3", + "url", + "x509-parser 0.18.1", + "zeroize", +] + [[package]] name = "spin" version = "0.9.9" @@ -6347,7 +6376,7 @@ dependencies = [ "time", "tokio", "toml 0.9.12+spec-1.1.0", - "x509-parser", + "x509-parser 0.17.0", "zeroize", ] @@ -6753,6 +6782,7 @@ dependencies = [ "async-trait", "base64 0.22.1", "bytes", + "h2 0.4.16", "http 1.5.0", "http-body", "http-body-util", @@ -6761,6 +6791,7 @@ dependencies = [ "hyper-util", "percent-encoding", "pin-project", + "socket2 0.6.5", "sync_wrapper", "tokio", "tokio-stream", @@ -7695,6 +7726,23 @@ dependencies = [ "time", ] +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror 2.0.19", + "time", +] + [[package]] name = "xts-mode" version = "0.5.1" diff --git a/crate/clients/ckms/src/actions/markdown.rs b/crate/clients/ckms/src/actions/markdown.rs index a60dd5c77c..295f7b1d19 100644 --- a/crate/clients/ckms/src/actions/markdown.rs +++ b/crate/clients/ckms/src/actions/markdown.rs @@ -155,9 +155,12 @@ fn write_subcommands<'a>( .replace(' ', "-") .replace('.', ""); write!(write, "**`{}`**", sub_command.get_name())?; - write!(write, " [[{index}]](#{sub_command_anchor}) ")?; + write!(write, " [[{index}]](#{sub_command_anchor})")?; if let Some(about) = sub_command.get_about() { - write!(write, " ")?; + // Two spaces keep the historical layout for subcommands that have a + // description; skipping the space entirely avoids trailing whitespace + // for subcommands without one (e.g. `fpe keys create`). + write!(write, " ")?; to_md(write, about)?; } writeln!(write)?; diff --git a/crate/clients/ckms/src/tests/login_tests.rs b/crate/clients/ckms/src/tests/login_tests.rs index f935778791..77500712fb 100644 --- a/crate/clients/ckms/src/tests/login_tests.rs +++ b/crate/clients/ckms/src/tests/login_tests.rs @@ -140,3 +140,64 @@ server_url = "http://127.0.0.1:1" "error message should mention 'AppRole login', got: {stderr}" ); } + +/// `ckms login spire --help` must succeed and print help text without +/// contacting any server or SPIRE Agent. +#[test] +pub(crate) fn test_ckms_login_spire_help() { + let mut cmd = ckms_bin(); + cmd.arg("login").arg("spire").arg("--help"); + cmd.assert().success(); +} + +/// `ckms login spire` without `--audience` must fail with clap's required argument error. +#[test] +pub(crate) fn test_ckms_login_spire_fails_without_audience() { + let mut cmd = ckms_bin(); + cmd.arg("login").arg("spire"); + + let output = recover_cmd_logs(&mut cmd); + assert!( + !output.status.success(), + "ckms login spire should fail without --audience" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("audience"), + "error message should mention 'audience', got: {stderr}" + ); +} + +/// `ckms login spire` with an unreachable SPIRE Agent Workload API must fail cleanly +/// (not panic) with an informative error mentioning the SPIRE Agent. +#[tokio::test] +pub(crate) async fn test_ckms_login_spire_fails_without_reachable_agent() { + let conf_path = env::temp_dir().join("ckms_login_spire_unreachable_test.toml"); + fs::write( + &conf_path, + r#" +[http_config] +server_url = "http://127.0.0.1:9998" +"#, + ) + .expect("failed to write test config"); + + let mut cmd = ckms_bin(); + cmd.env(CKMS_CONF_ENV, &conf_path) + .env_remove("SPIFFE_ENDPOINT_SOCKET") + .arg("login") + .arg("spire") + .arg("--audience") + .arg("test-audience"); + + let output = recover_cmd_logs(&mut cmd); + assert!( + !output.status.success(), + "ckms login spire should fail when SPIRE Agent is unreachable" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("SPIRE Agent"), + "error message should mention 'SPIRE Agent', got: {stderr}" + ); +} diff --git a/crate/clients/clap/Cargo.toml b/crate/clients/clap/Cargo.toml index 83d301c273..22fa33951b 100644 --- a/crate/clients/clap/Cargo.toml +++ b/crate/clients/clap/Cargo.toml @@ -54,6 +54,7 @@ reqwest = { workspace = true, features = ["json", "rustls-tls", "cookies"] } serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } +spiffe = { version = "0.16", features = ["workload-api", "jwt"] } strum = { workspace = true } thiserror = { workspace = true } time = { workspace = true } diff --git a/crate/clients/clap/src/actions/login.rs b/crate/clients/clap/src/actions/login.rs index 503223c9b6..be1f69a333 100644 --- a/crate/clients/clap/src/actions/login.rs +++ b/crate/clients/clap/src/actions/login.rs @@ -59,6 +59,13 @@ pub enum LoginCredential { /// Vault-compatible token at `POST {server_url}/v1/auth/approle/login`, which /// the KMS proxies to the auth-verifier. The token is stored and sent as an /// `X-Vault-Token` header on subsequent requests. +/// +/// **spire** — Fetch a SPIFFE JWT-SVID directly from the local SPIRE Agent's Workload +/// API (via a Unix domain socket, using the standard `SPIFFE_ENDPOINT_SOCKET` +/// environment variable unless `--socket-path` is given) and store it as the KMS +/// access token. Requires `--audience`, which must match a `--jwt-auth-provider` +/// audience configured on the KMS server (which must also be started with +/// `--jwt-svid-auth`). #[derive(Parser, Debug)] #[clap(verbatim_doc_comment)] pub struct LoginAction { @@ -103,6 +110,24 @@ pub enum LoginSubcommand { #[clap(long)] secret_id: Option, }, + /// Fetch a SPIFFE JWT-SVID from the local SPIRE Agent's Workload API and use it as + /// the KMS access token. + Spire { + /// The JWT audience value, forwarded to the Workload API's JWT-SVID fetch call. + /// Must match a `--jwt-auth-provider` audience configured on the KMS server. + #[clap(long)] + audience: String, + /// The SPIFFE ID of the JWT-SVID to request, when the local agent serves more + /// than one identity to this workload (optional — omit to accept whichever + /// identity the agent returns). + #[clap(long)] + spiffe_id: Option, + /// Local SPIRE Agent Workload API endpoint: an absolute socket path (e.g. + /// `/tmp/spire-agent/public/api.sock`) or a `unix:///path` / `tcp://host:port` + /// URI. When omitted, the `SPIFFE_ENDPOINT_SOCKET` environment variable is used. + #[clap(long)] + socket_path: Option, + }, } impl LoginAction { @@ -222,6 +247,85 @@ impl LoginAction { Ok(LoginCredential::VaultToken(vault_token)) } + LoginSubcommand::Spire { + audience, + spiffe_id, + socket_path, + } => { + let client = if let Some(path) = socket_path { + spiffe::WorkloadApiClient::connect_to(workload_api_endpoint(path)?).await + } else { + spiffe::WorkloadApiClient::connect_env().await + } + .map_err(|e| { + KmsCliError::Default(format!( + "failed to connect to the local SPIRE Agent Workload API: {e}" + )) + })?; + + let spiffe_id = spiffe_id + .as_deref() + .map(str::parse::) + .transpose() + .map_err(|e| KmsCliError::Default(format!("invalid --spiffe-id: {e}")))?; + + let jwt = client + .fetch_jwt_token([audience.as_str()], spiffe_id.as_ref()) + .await + .map_err(|e| { + KmsCliError::Default(format!( + "failed to fetch a JWT-SVID from the local SPIRE Agent: {e}" + )) + })?; + + println!("\nSuccess! The JWT-SVID was saved to the KMS client configuration."); + + Ok(LoginCredential::AccessToken(jwt)) + } + } + } +} + +/// Turn the `--socket-path` value into an endpoint URI understood by the SPIFFE Workload +/// API client, which only accepts `unix:` / `tcp:` URIs: a `unix:` / `tcp:` URI is passed +/// through unchanged and an absolute filesystem path gets the `unix://` scheme prepended. +/// Relative paths are rejected: `unix://./api.sock` would parse `.` as the host. +fn workload_api_endpoint(socket_path: &str) -> KmsCliResult { + if socket_path.starts_with("unix:") || socket_path.starts_with("tcp:") { + Ok(socket_path.to_owned()) + } else if socket_path.starts_with('/') { + Ok(format!("unix://{socket_path}")) + } else { + Err(KmsCliError::Default(format!( + "invalid --socket-path `{socket_path}`: expected an absolute path or a unix:/tcp: URI" + ))) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] // test assertions on known-good inputs +mod tests { + use super::workload_api_endpoint; + + #[test] + fn bare_socket_path_becomes_unix_uri() { + assert_eq!( + workload_api_endpoint("/tmp/spire-agent/public/api.sock").unwrap(), + "unix:///tmp/spire-agent/public/api.sock" + ); + } + + #[test] + fn uris_are_passed_through() { + for uri in ["unix:///run/spire/api.sock", "tcp://127.0.0.1:8081"] { + assert_eq!(workload_api_endpoint(uri).unwrap(), uri); + } + } + + #[test] + fn relative_paths_are_rejected() { + for path in ["./api.sock", "api.sock", ""] { + assert!(workload_api_endpoint(path).is_err(), "{path}"); } } } diff --git a/crate/server/documentation/openapi.yaml b/crate/server/documentation/openapi.yaml index 41403a31cd..42837d0bdb 100644 --- a/crate/server/documentation/openapi.yaml +++ b/crate/server/documentation/openapi.yaml @@ -3584,6 +3584,48 @@ paths: '502': description: Cosmian authentication server unreachable or returned an unexpected response + /ui/login_svid: + post: + tags: [UI Auth] + summary: Establish a Web UI session from a SPIFFE JWT-SVID + description: | + Intended for a gateway / BFF in front of the Web UI. Validates the posted JWT-SVID + (signature, issuer, expiry and audience) against the JWT issuers configured with + `--jwt-auth-provider` when `--jwt-svid-auth` is enabled, then stores the SPIFFE ID + (the `sub` claim, which must start with `spiffe://`) in the session cookie. Tokens + without a `spiffe://` subject (for example ordinary OIDC tokens carrying only an + `email`) and tokens without an `aud` claim are rejected. If validation fails the JWKS + is refreshed once and the token re-validated, so a SPIRE signing-key rotation does + not require a restart. + operationId: uiLoginSvid + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [jwt_svid] + properties: + jwt_svid: + type: string + description: The raw JWT-SVID (no `Bearer ` prefix) + responses: + '200': + description: Session established + content: + application/json: + schema: + type: object + required: [next_step] + properties: + next_step: + type: string + enum: [Authenticated] + '401': + description: The JWT-SVID is invalid, expired, has the wrong audience or is not a SPIFFE JWT-SVID + '500': + description: '`--jwt-svid-auth` is not enabled, or the session could not be stored' + /ui/callback: get: tags: [UI Auth] @@ -3646,8 +3688,10 @@ paths: summary: Auth method configuration description: >- Returns the authentication methods configured for the web UI as an ordered - `auth_methods` array (priority first: OIDC/JWT, then auth-verifier - username/password, then client certificate). The singular `auth_method` + `auth_methods` array (priority first: OIDC/JWT, then SPIFFE, then auth-verifier + username/password, then client certificate). `SPIFFE` is advertised when + `--jwt-svid-auth` is enabled; the session is established out of band through + `/ui/login_svid`. The singular `auth_method` field is the primary method (auth_methods[0]) or `None`, retained for backward compatibility. operationId: uiAuthMethod diff --git a/crate/server/kms_template.toml b/crate/server/kms_template.toml index ea4e57dbaa..fd29a9b446 100644 --- a/crate/server/kms_template.toml +++ b/crate/server/kms_template.toml @@ -245,6 +245,15 @@ hostname = "0.0.0.0" # "https://keycloak.example.com/auth/realms/myrealm,," # ] +# Accept SPIFFE JWT-SVIDs from the configured `--jwt-auth-provider` issuers. +# +# A SPIFFE JWT-SVID carries no `email` claim, only a `sub` claim shaped as `spiffe:///`. When this flag is enabled, a JWT that validates successfully (signature, issuer, audience, expiry) against a configured issuer but has no `email` claim is authenticated using its `sub` claim **only if** `sub` starts with `spiffe://`; every other JWT still requires `email` as before. +# +# The flag is global: it applies to every `--jwt-auth-provider`. Every provider MUST specify an audience (`issuer,jwks_uri,audience`), and the SVID MUST carry a matching `aud` claim; otherwise the server refuses to start, since an SVID minted for another service could be replayed against the KMS. +# +# Disabled by default: enabling it only makes sense when the configured issuer(s) are a SPIFFE-aware JWKS source (e.g. a SPIRE OIDC Discovery Provider). +# jwt_svid_auth = false + [workspace] # The root folder where the KMS will store its data A relative path is taken relative to the user's HOME directory # root_data_path = "./cosmian-kms" diff --git a/crate/server/src/config/command_line/idp_auth_config.rs b/crate/server/src/config/command_line/idp_auth_config.rs index 1a4df871b0..63f9e7db21 100644 --- a/crate/server/src/config/command_line/idp_auth_config.rs +++ b/crate/server/src/config/command_line/idp_auth_config.rs @@ -31,6 +31,24 @@ pub struct IdpAuthConfig { /// This argument can be repeated to configure multiple identity providers. #[clap(verbatim_doc_comment, long, env = "KMS_JWT_AUTH_PROVIDER", action = clap::ArgAction::Append)] pub jwt_auth_provider: Option>, + + /// Accept SPIFFE JWT-SVIDs from the configured `--jwt-auth-provider` issuers. + /// + /// A SPIFFE JWT-SVID carries no `email` claim, only a `sub` claim shaped as + /// `spiffe:///`. When this flag is enabled, a JWT that + /// validates successfully (signature, issuer, audience, expiry) against a configured + /// issuer but has no `email` claim is authenticated using its `sub` claim **only if** + /// `sub` starts with `spiffe://`; every other JWT still requires `email` as before. + /// + /// The flag is global: it applies to every `--jwt-auth-provider`. Every provider MUST + /// specify an audience (`issuer,jwks_uri,audience`), and the SVID MUST carry a matching + /// `aud` claim; otherwise the server refuses to start, since an SVID minted for another + /// service could be replayed against the KMS. + /// + /// Disabled by default: enabling it only makes sense when the configured issuer(s) are + /// a SPIFFE-aware JWKS source (e.g. a SPIRE OIDC Discovery Provider). + #[clap(long, env = "KMS_JWT_SVID_AUTH")] + pub jwt_svid_auth: bool, } impl IdpAuthConfig { @@ -128,9 +146,31 @@ mod tests { "https://issuer1.com,https://jwks1.com,key1,key2".to_owned(), // Duplicate "https://issuer3.com,,".to_owned(), ]), + jwt_svid_auth: false, }; let extracted = idp_list.extract_idp_configs().unwrap().unwrap(); assert_eq!(extracted.len(), 3); // One duplicate should be removed info!("Extracted IDP Configs: {:#?}", extracted); } + + #[test] + fn jwt_svid_auth_defaults_to_false() { + let idp_auth_config = IdpAuthConfig::default(); + assert!(!idp_auth_config.jwt_svid_auth); + } + + /// `jwt_svid_auth` is a server-wide opt-in flag; it must not influence how + /// `--jwt-auth-provider` entries are parsed/deduplicated (non-regression). + #[test] + #[allow(clippy::unwrap_used)] + fn jwt_svid_auth_does_not_affect_provider_extraction() { + let idp_list = IdpAuthConfig { + jwt_auth_provider: Some(vec![ + "https://issuer1.com,https://jwks1.com,key1".to_owned(), + ]), + jwt_svid_auth: true, + }; + let extracted = idp_list.extract_idp_configs().unwrap().unwrap(); + assert_eq!(extracted.len(), 1); + } } diff --git a/crate/server/src/config/params/server_params.rs b/crate/server/src/config/params/server_params.rs index bdf80112db..9f5f585806 100644 --- a/crate/server/src/config/params/server_params.rs +++ b/crate/server/src/config/params/server_params.rs @@ -46,6 +46,11 @@ pub struct ServerParams { /// The JWT Config if Auth is enabled pub identity_provider_configurations: Option>, + /// When `true`, JWTs from `identity_provider_configurations` issuers that have no + /// `email` claim are authenticated using their `sub` claim, provided it starts with + /// `spiffe://` (SPIFFE JWT-SVID support). See `IdpAuthConfig::jwt_svid_auth`. + pub jwt_svid_auth_enabled: bool, + /// The UI distribution folder pub ui_index_html_folder: PathBuf, @@ -395,6 +400,19 @@ impl ServerParams { // include it in the CORS allow-list when cors_allowed_origins is not configured. let public_url_for_cors = conf.kms_public_url.clone(); + // Capture before `conf.idp_auth` is consumed by `extract_idp_configs` below. + let jwt_svid_auth_enabled = conf.idp_auth.jwt_svid_auth; + + // Try the new IdpAuthConfig first, then fall back to the deprecated JwtAuthConfig + let identity_provider_configurations = conf + .idp_auth + .extract_idp_configs() + .context("failed initializing IdPs from idp_auth")?; + + if jwt_svid_auth_enabled { + ensure_svid_providers_have_audience(identity_provider_configurations.as_deref())?; + } + // Determine whether CO users will come from the deprecated `privileged_users` path. // Used after `res` is built to preserve v5.26.0 behaviour: if the operator had // `force_default_username = true` AND `privileged_users = [...]` (nonsensical but @@ -403,12 +421,8 @@ impl ServerParams { conf.roles.crypto_officer_users.is_none() && conf.privileged_users.is_some(); let res = Self { - identity_provider_configurations: { - // Try the new IdpAuthConfig first, then fall back to the deprecated JwtAuthConfig - conf.idp_auth - .extract_idp_configs() - .context("failed initializing IdPs from idp_auth")? - }, + identity_provider_configurations, + jwt_svid_auth_enabled, ui_index_html_folder, ui_enable: conf.ui_config.enable, ui_oidc_auth: conf.ui_config.ui_oidc_auth, @@ -821,6 +835,27 @@ fn parse_default_unwrap_types(types: Option>) -> KResult) -> KResult<()> { + let Some(providers) = providers else { + return Err(KmsError::ServerError( + "`jwt_svid_auth` is enabled but no `jwt_auth_provider` is configured".to_owned(), + )); + }; + if let Some(provider) = providers.iter().find(|idp| idp.jwt_audience.is_none()) { + return Err(KmsError::ServerError(format!( + "`jwt_svid_auth` requires an audience on every `jwt_auth_provider`, but the \ + provider for issuer `{}` has none. Append the expected audience \ + (`issuer,jwks_uri,audience`) so SVIDs minted for other services are rejected.", + provider.jwt_issuer_uri + ))); + } + Ok(()) +} + impl fmt::Debug for ServerParams { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let mut debug_struct = f.debug_struct("ServerParams"); @@ -829,6 +864,7 @@ impl fmt::Debug for ServerParams { if let Some(ref idp_configs) = self.identity_provider_configurations { debug_struct.field("identity_provider_configurations", idp_configs); } + debug_struct.field("jwt_svid_auth_enabled", &self.jwt_svid_auth_enabled); // Always show these non-optional fields debug_struct @@ -1121,12 +1157,38 @@ impl fmt::Debug for ServerParams { mod tests { use tempfile::TempDir; - use super::ServerParams; + use super::{ServerParams, ensure_svid_providers_have_audience}; use crate::{ - config::{ClapConfig, HttpConfig, command_line::MainDBConfig}, + config::{ClapConfig, HttpConfig, IdpConfig, command_line::MainDBConfig}, tests::test_utils::https_clap_config, }; + fn provider(issuer: &str, audience: Option<&str>) -> IdpConfig { + IdpConfig { + jwt_issuer_uri: issuer.to_owned(), + jwks_uri: None, + jwt_audience: audience.map(|a| vec![a.to_owned()]), + } + } + + /// `--jwt-svid-auth` is global, so a single provider without an audience would let + /// SVIDs minted for other services through: startup must be refused and name the issuer. + #[test] + fn jwt_svid_auth_requires_audience_on_every_provider() { + let providers = [ + provider("https://with-aud.example.org", Some("cosmian-kms")), + provider("https://no-aud.example.org", None), + ]; + let error = ensure_svid_providers_have_audience(Some(&providers)) + .expect_err("provider without audience must be refused"); + assert!(error.to_string().contains("https://no-aud.example.org")); + + ensure_svid_providers_have_audience(Some(&providers[..1])) + .expect("all providers have an audience"); + ensure_svid_providers_have_audience(None) + .expect_err("jwt_svid_auth without any provider must be refused"); + } + /// Build a minimal [`ClapConfig`] that uses a `SQLite` database in `tmp_dir`. fn minimal_config(tmp_dir: &TempDir) -> ClapConfig { ClapConfig { diff --git a/crate/server/src/config/wizard/auth_wizard.rs b/crate/server/src/config/wizard/auth_wizard.rs index 33c8f8099c..bccff1fd13 100644 --- a/crate/server/src/config/wizard/auth_wizard.rs +++ b/crate/server/src/config/wizard/auth_wizard.rs @@ -13,6 +13,22 @@ use crate::{ result::KResult, }; +/// `true` when a `ISSUER_URI[,JWKS_URI[,AUDIENCE...]]` provider string has a non-empty audience. +fn provider_has_audience(provider: &str) -> bool { + provider + .split(',') + .skip(2) + .any(|audience| !audience.trim().is_empty()) +} + +/// Append `audience` to a provider string that has none, keeping the (possibly empty) JWKS URI. +fn with_audience(provider: &str, audience: &str) -> String { + let mut parts = provider.split(','); + let issuer = parts.next().unwrap_or_default().trim(); + let jwks_uri = parts.next().unwrap_or_default().trim(); + format!("{issuer},{jwks_uri},{audience}") +} + pub struct AuthWizardResult { pub idp_auth: IdpAuthConfig, /// Auth Verifier server configuration to wire into `ClapConfig.auth_verifier`. @@ -54,6 +70,7 @@ pub fn configure_auth(http: &mut HttpConfig, ui: &mut UiConfig) -> KResult = Vec::new(); + let mut jwt_svid_auth = false; let mut ui_oidc = OidcConfig::default(); let mut auth_verifier = AuthVerifierConfig::default(); @@ -80,6 +97,33 @@ pub fn configure_auth(http: &mut HttpConfig, ui: &mut UiConfig) -> KResult/...), e.g. a \ + SPIRE OIDC Discovery Provider?", + ) + .default(false) + .interact() + .map_err(|e| KmsError::ServerError(format!("Prompt error: {e}")))?; + + // The server refuses to start with `jwt_svid_auth` unless every provider has an + // audience: ask for the missing ones rather than writing a config that cannot start. + if jwt_svid_auth { + for provider in &mut jwt_providers { + if provider_has_audience(provider) { + continue; + } + let audience: String = Input::with_theme(&theme) + .with_prompt(format!( + "Audience required for SPIFFE JWT-SVIDs, provider `{provider}`" + )) + .interact_text() + .map_err(|e| KmsError::ServerError(format!("Prompt error: {e}")))?; + *provider = with_audience(provider, audience.trim()); + } + } + // UI OIDC let configure_ui_oidc = Confirm::with_theme(&theme) .with_prompt("Configure OIDC for the web UI?") @@ -198,9 +242,37 @@ pub fn configure_auth(http: &mut HttpConfig, ui: &mut UiConfig) -> KResult KResult<()> { if ALLOWED_JWT_ALGORITHMS.contains(&alg) { Ok(()) @@ -150,6 +150,11 @@ pub struct JwtConfig { pub jwt_issuer_uri: String, pub jwt_audience: Option>, pub jwks: Arc, + /// When `true`, a token from this issuer that has no `email` claim is authenticated + /// using its `sub` claim, provided `sub` starts with `spiffe://` (SPIFFE JWT-SVID). + /// Defaults to `false` so existing OIDC/IdP issuers keep requiring `email`. Only set + /// from the operator-controlled `--jwt-svid-auth` flag; Google CSE issuers never set it. + pub accept_spiffe_subject: bool, } impl JwtConfig { @@ -198,64 +203,79 @@ impl JwtConfig { // In production, fully validate: issuer, expiry, audience, and signature via JWKS. #[cfg(all(not(test), not(feature = "insecure")))] { - let header = decode_header(token).map_err(|e| { - KmsError::Unauthorized(format!("Failed to decode token header: {e}")) - })?; - - // Reject symmetric / unknown algorithms before touching the JWKS key material. - check_jwt_algorithm(header.alg)?; - - let mut validation = Validation::new(header.alg); - // Explicitly pin the allowed algorithms to the single pre-validated algorithm. - // This prevents jsonwebtoken from accepting any algorithm not in the allowlist. - validation.algorithms = vec![header.alg]; - validation.set_issuer(&[&self.jwt_issuer_uri]); - validation.validate_exp = true; - validation.required_spec_claims.clear(); - if validate_subject { - // Require both subject and expiration in production - validation.set_required_spec_claims(&["sub", "exp"]); - } else { - // At minimum, always require expiration - validation.set_required_spec_claims(&["exp"]); - } - if let Some(jwt_audience) = &self.jwt_audience { - validation.set_audience(jwt_audience.as_slice()); - } else { - // jsonwebtoken 10.x rejects tokens that carry an `aud` claim when no - // expected audience is configured in the Validation struct (InvalidAudience). - // When the server does not restrict by audience, skip audience validation. - validation.validate_aud = false; - } - - // OIDC/IdP tokens are required to carry a `kid` so we can look up the exact - // signing key in the JWKS. Auth-verifier tokens intentionally omit `kid` - // (they are validated by the dedicated `AuthVerifier` middleware which tries - // every key in the JWKS). Return `Unauthorized` here so the middleware chain - // falls through to the next authenticator rather than logging a noisy error. - let Some(kid) = header.kid else { - return Err(KmsError::Unauthorized( - "No 'kid' claim present in token — not an OIDC token".to_owned(), - )); - }; - - let jwk = self.jwks.find(&kid)?.ok_or_else(|| { - KmsError::Unauthorized(format!( - "Specified key not found in set. Looking for kid `{kid}`" - )) - })?; - - trace!("JWK has been found:\n{jwk:?}"); - - let decoding_key = DecodingKey::from_jwk(&jwk).map_err(|e| { - KmsError::Unauthorized(format!("Failed to build decoding key from JWK: {e}")) - })?; - - let token_data = decode::(token, &decoding_key, &validation) - .map_err(|e| KmsError::Unauthorized(format!("Cannot validate token: {e}")))?; + self.validate_signed_token(token, validate_subject) + } + } - Ok(token_data.claims) + /// Fully validate a JWT: algorithm allow-list, `kid` lookup in the JWKS, signature, + /// issuer, expiry and (when configured) audience. + /// + /// This is the production validation path. It is compiled in every build except + /// `insecure` so that unit tests can exercise it directly even though + /// [`Self::validate_authentication_token`] short-circuits to an unverified decode + /// under `cfg(test)`. + #[cfg(not(feature = "insecure"))] + pub(crate) fn validate_signed_token( + &self, + token: &str, + validate_subject: bool, + ) -> KResult { + let header = decode_header(token) + .map_err(|e| KmsError::Unauthorized(format!("Failed to decode token header: {e}")))?; + + // Reject symmetric / unknown algorithms before touching the JWKS key material. + check_jwt_algorithm(header.alg)?; + + let mut validation = Validation::new(header.alg); + // Explicitly pin the allowed algorithms to the single pre-validated algorithm. + // This prevents jsonwebtoken from accepting any algorithm not in the allowlist. + validation.algorithms = vec![header.alg]; + validation.set_issuer(&[&self.jwt_issuer_uri]); + validation.validate_exp = true; + validation.required_spec_claims.clear(); + if validate_subject { + // Require both subject and expiration in production + validation.set_required_spec_claims(&["sub", "exp"]); + } else { + // At minimum, always require expiration + validation.set_required_spec_claims(&["exp"]); + } + if let Some(jwt_audience) = &self.jwt_audience { + validation.set_audience(jwt_audience.as_slice()); + } else { + // jsonwebtoken 10.x rejects tokens that carry an `aud` claim when no + // expected audience is configured in the Validation struct (InvalidAudience). + // When the server does not restrict by audience, skip audience validation. + validation.validate_aud = false; } + + // OIDC/IdP tokens are required to carry a `kid` so we can look up the exact + // signing key in the JWKS. Auth-verifier tokens intentionally omit `kid` + // (they are validated by the dedicated `AuthVerifier` middleware which tries + // every key in the JWKS). Return `Unauthorized` here so the middleware chain + // falls through to the next authenticator rather than logging a noisy error. + let Some(kid) = header.kid else { + return Err(KmsError::Unauthorized( + "No 'kid' claim present in token — not an OIDC token".to_owned(), + )); + }; + + let jwk = self.jwks.find(&kid)?.ok_or_else(|| { + KmsError::Unauthorized(format!( + "Specified key not found in set. Looking for kid `{kid}`" + )) + })?; + + trace!("JWK has been found:\n{jwk:?}"); + + let decoding_key = DecodingKey::from_jwk(&jwk).map_err(|e| { + KmsError::Unauthorized(format!("Failed to build decoding key from JWK: {e}")) + })?; + + let token_data = decode::(token, &decoding_key, &validation) + .map_err(|e| KmsError::Unauthorized(format!("Cannot validate token: {e}")))?; + + Ok(token_data.claims) } } diff --git a/crate/server/src/middlewares/jwt/jwt_token_auth.rs b/crate/server/src/middlewares/jwt/jwt_token_auth.rs index 2f57d1cbc9..a9c00df127 100644 --- a/crate/server/src/middlewares/jwt/jwt_token_auth.rs +++ b/crate/server/src/middlewares/jwt/jwt_token_auth.rs @@ -19,6 +19,18 @@ use crate::{ result::KResult, }; +/// URI scheme prefix identifying a SPIFFE ID (e.g. `spiffe://example.org/ns/foo/sa/bar`), +/// as carried by the `sub` claim of a SPIFFE JWT-SVID. +const SPIFFE_ID_PREFIX: &str = "spiffe://"; + +/// `true` when `sub` is a SPIFFE ID: the `spiffe://` scheme followed by a non-empty trust +/// domain (a bare `spiffe://` or `spiffe:///path` never names a workload). +fn is_spiffe_id(sub: &str) -> bool { + sub.strip_prefix(SPIFFE_ID_PREFIX) + .and_then(|rest| rest.split('/').next()) + .is_some_and(|trust_domain| !trust_domain.is_empty()) +} + /// Attempts to extract and validate a user claim from a JWT token /// /// Tries each provided JWT configuration until one successfully validates the token or all configurations fail. @@ -28,15 +40,19 @@ use crate::{ /// * `token` - The JWT token string /// /// # Returns -/// * `Ok(UserClaim)` - Successfully validated user claim +/// * `Ok((UserClaim, bool))` - Successfully validated user claim, plus the +/// `accept_spiffe_subject` flag of the configuration that validated it /// * `Err(Vec)` - List of errors from failed validation attempts -fn extract_user_claim(configs: &[JwtConfig], token: &str) -> Result> { +fn extract_user_claim( + configs: &[JwtConfig], + token: &str, +) -> Result<(UserClaim, bool), Vec> { let mut jwt_log_errors = Vec::new(); // Try each JWT configuration until one succeeds for idp_config in configs { match idp_config.decode_bearer_header(token) { - Ok(user_claim) => return Ok(user_claim), + Ok(user_claim) => return Ok((user_claim, idp_config.accept_spiffe_subject)), Err(error) => { jwt_log_errors.push(error); } @@ -47,6 +63,135 @@ fn extract_user_claim(configs: &[JwtConfig], token: &str) -> Result KResult { + let sub = user_claim + .sub + .as_deref() + .filter(|sub| is_spiffe_id(sub)) + .ok_or_else(|| { + KmsError::InvalidRequest("JWT-SVID subject must be a spiffe:// ID".to_owned()) + })?; + if !user_claim + .aud + .as_ref() + .is_some_and(|aud| aud.iter().any(|audience| !audience.is_empty())) + { + warn!("JWT-SVID for {sub} rejected: missing or empty 'aud' claim"); + return Err(KmsError::InvalidRequest( + "JWT-SVID must carry a non-empty 'aud' claim".to_owned(), + )); + } + debug!("JWT-SVID access granted to {sub}!"); + let username = UserId::from(sub.to_owned()); + reject_reserved_aws_xks_identity(&username)?; + Ok(AuthenticatedUser { + username, + auth_method: AuthMethod::JwtSvid, + }) +} + +/// Resolve an [`AuthenticatedUser`] from a successfully validated JWT claim set. +/// +/// `email` takes priority when present (standard OIDC/IdP flow, unchanged behaviour). When +/// `email` is absent, falls back to the `sub` claim **only if** `accept_spiffe_subject` is +/// `true` (the issuer's config opted in via `--jwt-svid-auth`) **and** `sub` is a SPIFFE ID +/// (`spiffe://...`), i.e. a SPIFFE JWT-SVID (see [`spiffe_authenticated_user`]). Every other +/// case is rejected, preserving the pre-existing "no email in JWT" behaviour. +fn resolve_authenticated_user( + user_claim: &UserClaim, + accept_spiffe_subject: bool, +) -> KResult { + if let Some(email) = user_claim.email.as_deref() { + // Authentication successful with valid email + debug!("JWT Access granted to {email}!"); + let username = UserId::from(email.to_owned()); + reject_reserved_aws_xks_identity(&username)?; + return Ok(AuthenticatedUser { + username, + auth_method: AuthMethod::OidcJwt, + }); + } + let has_spiffe_subject = user_claim.sub.as_deref().is_some_and(is_spiffe_id); + if accept_spiffe_subject && has_spiffe_subject { + // SPIFFE JWT-SVID: no email claim, but a validated spiffe:// subject and the + // issuer's config explicitly opted in via `--jwt-svid-auth`. + return spiffe_authenticated_user(user_claim); + } + // JWT is valid but missing the required email claim (and either SPIFFE JWT-SVID + // support is not enabled for this issuer, or `sub` is not a spiffe:// URI) + warn!("no email in JWT"); + Err(KmsError::InvalidRequest("No email in JWT".to_owned())) +} + +/// Validate `token` against every SPIFFE-enabled configuration, returning the first +/// successfully validated claim set or the collected validation errors. +fn extract_svid_claim( + spiffe_configs: &[&JwtConfig], + token: &str, +) -> Result> { + let mut errors = Vec::new(); + for config in spiffe_configs { + match config.validate_authentication_token(token, true) { + Ok(claim) => return Ok(claim), + Err(error) => errors.push(error), + } + } + Err(errors) +} + +/// Validate a raw JWT-SVID token (no "Bearer " prefix, no `Authorization` header) +/// against the SPIFFE-JWT-SVID-enabled issuers and resolve an `AuthenticatedUser`. +/// +/// Used by the Web UI's `/ui/login_svid` endpoint so a browser session can be established +/// with a SPIRE-issued JWT-SVID, reusing the same issuer/JWKS validation as the bearer-token +/// path (`handle_jwt`) without requiring the Authorization header framing. +/// +/// Unlike the bearer path this endpoint only ever accepts SPIFFE subjects: a token that +/// merely carries an `email` claim is rejected, so `/ui/login_svid` cannot be used to turn an +/// ordinary OIDC bearer token into a 24h browser session. +/// +/// When validation fails, the JWKS is refreshed once (throttled) and validation retried, so a +/// SPIRE signing-key rotation, or a JWKS endpoint that was unreachable at startup, does not +/// break the login until the next restart. +pub(crate) async fn validate_jwt_svid( + configs: &[JwtConfig], + token: &str, +) -> KResult { + let spiffe_configs: Vec<&JwtConfig> = configs + .iter() + .filter(|config| config.accept_spiffe_subject) + .collect(); + + let mut claim = extract_svid_claim(&spiffe_configs, token); + if claim.is_err() { + if let Some(config) = spiffe_configs.first() { + if let Err(error) = config.jwks.refresh().await { + warn!("JWKS refresh failed while validating a JWT-SVID: {error:?}"); + } + } + claim = extract_svid_claim(&spiffe_configs, token); + } + + match claim { + Ok(user_claim) => spiffe_authenticated_user(&user_claim), + Err(errors) => { + for error in &errors { + warn!("{error:?}"); + } + Err(KmsError::InvalidRequest("bad JWT-SVID".to_owned())) + } + } +} + /// Core JWT authentication logic /// /// Extracts the JWT token from the request, validates it, and checks @@ -95,27 +240,16 @@ pub(super) async fn handle_jwt( private_claim = extract_user_claim(&configs, &identity); } - // Process the validation result and extract the email claim - match private_claim.map(|user_claim| user_claim.email) { - Ok(Some(email)) => { - // Authentication successful with valid email - debug!("JWT Access granted to {email}!"); - let username = UserId::from(email); - reject_reserved_aws_xks_identity(&username)?; - Ok(AuthenticatedUser { - username, - auth_method: AuthMethod::OidcJwt, + match private_claim { + Ok((user_claim, accept_spiffe_subject)) => { + resolve_authenticated_user(&user_claim, accept_spiffe_subject).inspect_err(|error| { + warn!( + "{:?} {} 401 unauthorized: {error}", + req.method(), + req.path() + ); }) } - Ok(None) => { - // JWT is valid but missing the required email claim — log as WARN for audit trail - warn!( - "{:?} {} 401 unauthorized, no email in JWT", - req.method(), - req.path() - ); - Err(KmsError::InvalidRequest("No email in JWT".to_owned())) - } Err(jwt_log_errors) => { // JWT validation failed — log at WARN so auth failures appear in production logs for error in &jwt_log_errors { @@ -130,3 +264,394 @@ pub(super) async fn handle_jwt( } } } + +#[cfg(test)] +#[allow(clippy::expect_used)] +mod tests { + use std::sync::Arc; + + use jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; + + use super::{ + AuthMethod, JwtConfig, UserClaim, extract_user_claim, resolve_authenticated_user, + validate_jwt_svid, + }; + use crate::middlewares::{UserId, jwt::JwksManager}; + + const SPIFFE_ID: &str = "spiffe://example.org/ns/foo/sa/bar"; + + fn claim(sub: Option<&str>, email: Option<&str>) -> UserClaim { + UserClaim { + email: email.map(str::to_owned), + iss: None, + sub: sub.map(str::to_owned), + aud: None, + iat: None, + exp: None, + nbf: None, + jti: None, + role: None, + resource_name: None, + perimeter_id: None, + kacls_url: None, + spki_hash: None, + spki_hash_algorithm: None, + message_id: None, + email_type: None, + google_email: None, + } + } + + /// A SPIFFE JWT-SVID claim set: `spiffe://` subject, no `email`, with an audience. + fn svid_claim(sub: &str) -> UserClaim { + UserClaim { + aud: Some(vec!["cosmian-kms".to_owned()]), + ..claim(Some(sub), None) + } + } + + /// Existing OIDC behaviour must be unaffected: `email` present is always accepted, + /// regardless of `accept_spiffe_subject`. + #[test] + fn email_claim_is_accepted_and_takes_priority() { + let user_claim = claim(Some(SPIFFE_ID), Some("a@b.com")); + let user = resolve_authenticated_user(&user_claim, true).expect("must be accepted"); + assert_eq!(user.username, UserId::from("a@b.com")); + assert_eq!(user.auth_method, AuthMethod::OidcJwt); + } + + /// A SPIFFE JWT-SVID (`sub = spiffe://...`, no `email`) is accepted only when + /// `accept_spiffe_subject` is enabled — this is the `--jwt-svid-auth` opt-in flag. + #[test] + fn spiffe_subject_accepted_when_flag_enabled() { + let user = resolve_authenticated_user(&svid_claim(SPIFFE_ID), true) + .expect("SPIFFE sub must be accepted"); + assert_eq!(user.username, UserId::from(SPIFFE_ID)); + assert_eq!(user.auth_method, AuthMethod::JwtSvid); + } + + /// Non-regression: the exact same token must still be rejected when the operator has + /// not enabled `--jwt-svid-auth` for this issuer. + #[test] + fn spiffe_subject_rejected_when_flag_disabled() { + let error = resolve_authenticated_user(&svid_claim(SPIFFE_ID), false) + .expect_err("must be rejected when accept_spiffe_subject is false"); + assert!(error.to_string().contains("No email in JWT")); + } + + /// A `sub` that is not a SPIFFE ID must never be accepted as a username, even when the + /// flag is enabled — the fallback is strictly scoped to `spiffe://` subjects. + #[test] + fn non_spiffe_subject_rejected_even_when_flag_enabled() { + for sub in ["not-a-spiffe-id", "spiffe://", "spiffe:///no-trust-domain"] { + let error = resolve_authenticated_user(&svid_claim(sub), true) + .expect_err("non-spiffe sub must never be accepted as a username"); + assert!(error.to_string().contains("No email in JWT"), "{sub}"); + } + } + + /// No `sub` and no `email` must be rejected regardless of the flag. + #[test] + fn no_subject_no_email_rejected() { + let error = resolve_authenticated_user(&claim(None, None), true) + .expect_err("must be rejected without email or sub"); + assert!(error.to_string().contains("No email in JWT")); + } + + /// An SVID without an `aud` claim (or with only empty audiences) is never a valid KMS + /// credential: `jsonwebtoken` skips audience matching when the claim is absent, so this is + /// the only check preventing replay of an SVID minted for another service. + #[test] + fn spiffe_subject_without_audience_rejected() { + for aud in [None, Some(vec![]), Some(vec![String::new()])] { + let user_claim = UserClaim { + aud, + ..claim(Some(SPIFFE_ID), None) + }; + let error = resolve_authenticated_user(&user_claim, true) + .expect_err("SVID without a usable audience must be rejected"); + assert!(error.to_string().contains("'aud'"), "{error}"); + } + } + + fn sign_test_jwt(claims: &UserClaim) -> String { + let mut header = Header::new(Algorithm::HS256); + header.kid = Some("test-kid".to_owned()); + encode(&header, claims, &EncodingKey::from_secret(b"test-secret")) + .expect("failed to sign test JWT") + } + + async fn empty_jwks_manager() -> JwksManager { + JwksManager::new(vec![], None) + .await + .expect("empty JwksManager must build without network access") + } + + async fn config(accept_spiffe_subject: bool) -> JwtConfig { + JwtConfig { + jwt_issuer_uri: "https://issuer.example.com".to_owned(), + jwt_audience: Some(vec!["cosmian-kms".to_owned()]), + jwks: Arc::new(empty_jwks_manager().await), + accept_spiffe_subject, + } + } + + /// `extract_user_claim` must surface the `accept_spiffe_subject` flag of whichever + /// configuration validated the token, so `handle_jwt` can apply the SPIFFE fallback. + #[tokio::test] + async fn extract_user_claim_surfaces_accept_spiffe_subject_flag() { + let configs = vec![config(true).await]; + let token = sign_test_jwt(&svid_claim(SPIFFE_ID)); + + let (user_claim, accept_spiffe_subject) = + extract_user_claim(&configs, &format!("Bearer {token}")) + .expect("token must be decoded in test/insecure mode"); + + assert!(accept_spiffe_subject); + assert_eq!(user_claim.sub.as_deref(), Some(SPIFFE_ID)); + } + + /// `/ui/login_svid` only trusts issuers that opted in through `--jwt-svid-auth`. + #[tokio::test] + async fn validate_jwt_svid_ignores_configs_without_spiffe_opt_in() { + let configs = vec![config(false).await]; + let token = sign_test_jwt(&svid_claim(SPIFFE_ID)); + validate_jwt_svid(&configs, &token) + .await + .expect_err("issuer without accept_spiffe_subject must not validate SVIDs"); + } + + /// The SVID login is not a generic bearer-to-session exchange: a token that only carries + /// an `email` (ordinary OIDC token) must be rejected even though the issuer validates it. + #[tokio::test] + async fn validate_jwt_svid_rejects_email_tokens_without_spiffe_subject() { + let configs = vec![config(true).await]; + let token = sign_test_jwt(&UserClaim { + aud: Some(vec!["cosmian-kms".to_owned()]), + ..claim(Some("oidc-subject"), Some("user@example.com")) + }); + validate_jwt_svid(&configs, &token) + .await + .expect_err("email-only tokens must not be exchangeable for an SVID session"); + + // Even an `email` alongside a SPIFFE subject is resolved as a SPIFFE identity. + let token = sign_test_jwt(&UserClaim { + aud: Some(vec!["cosmian-kms".to_owned()]), + ..claim(Some(SPIFFE_ID), Some("user@example.com")) + }); + let user = validate_jwt_svid(&configs, &token) + .await + .expect("SPIFFE subject must be accepted"); + assert_eq!(user.username, UserId::from(SPIFFE_ID)); + assert_eq!(user.auth_method, AuthMethod::JwtSvid); + } + + #[tokio::test] + async fn validate_jwt_svid_rejects_svid_without_audience() { + let configs = vec![config(true).await]; + let token = sign_test_jwt(&claim(Some(SPIFFE_ID), None)); + validate_jwt_svid(&configs, &token) + .await + .expect_err("SVID without aud must be rejected"); + } +} + +/// Tests that run the real signature/issuer/audience/expiry validation +/// ([`JwtConfig::validate_signed_token`]). Every other test in this crate goes through +/// `insecure_decode` (`cfg(test)`), so this module is what actually covers the production +/// validation path for SPIFFE JWT-SVIDs. +#[cfg(test)] +#[cfg(not(feature = "insecure"))] +#[allow(clippy::expect_used)] +mod real_validation { + use std::{collections::HashMap, sync::Arc}; + + use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; + use jsonwebtoken::{Algorithm, EncodingKey, Header, encode, jwk::JwkSet}; + use openssl::{ + bn::{BigNum, BigNumContext}, + ec::{EcGroup, EcKey}, + nid::Nid, + pkey::PKey, + }; + use serde_json::{Value, json}; + + use super::{AuthMethod, JwtConfig, spiffe_authenticated_user}; + use crate::middlewares::jwt::JwksManager; + + const ISSUER: &str = "https://spire.example.org"; + const AUDIENCE: &str = "cosmian-kms"; + const SPIFFE_ID: &str = "spiffe://example.org/ns/foo/sa/bar"; + const KID: &str = "svid-key-1"; + + /// An ES256 signing key together with its public JWK. + struct TestKey { + encoding_key: EncodingKey, + jwk: Value, + } + + fn generate_key() -> TestKey { + let group = EcGroup::from_curve_name(Nid::X9_62_PRIME256V1).expect("P-256 group"); + let ec_key = EcKey::generate(&group).expect("EC key generation"); + let mut ctx = BigNumContext::new().expect("bn ctx"); + let (mut x, mut y) = (BigNum::new().expect("bn"), BigNum::new().expect("bn")); + ec_key + .public_key() + .affine_coordinates(&group, &mut x, &mut y, &mut ctx) + .expect("affine coordinates"); + let pem = PKey::from_ec_key(ec_key) + .expect("pkey") + .private_key_to_pem_pkcs8() + .expect("PKCS#8 PEM"); + TestKey { + encoding_key: EncodingKey::from_ec_pem(&pem).expect("encoding key"), + jwk: json!({ + "kty": "EC", + "crv": "P-256", + "alg": "ES256", + "use": "sig", + "kid": KID, + "x": URL_SAFE_NO_PAD.encode(x.to_vec_padded(32).expect("x")), + "y": URL_SAFE_NO_PAD.encode(y.to_vec_padded(32).expect("y")), + }), + } + } + + /// A `JwtConfig` trusting `trusted` for `KID`, issuer `ISSUER`, audience `AUDIENCE`. + async fn config(trusted: &TestKey) -> JwtConfig { + let jwks = JwksManager::new(vec![], None) + .await + .expect("empty JwksManager must build without network access"); + let jwk_set: JwkSet = + serde_json::from_value(json!({ "keys": [trusted.jwk.clone()] })).expect("JWK set"); + *jwks.jwks.write().expect("jwks lock") = HashMap::from([("test".to_owned(), jwk_set)]); + JwtConfig { + jwt_issuer_uri: ISSUER.to_owned(), + jwt_audience: Some(vec![AUDIENCE.to_owned()]), + jwks: Arc::new(jwks), + accept_spiffe_subject: true, + } + } + + fn now() -> i64 { + chrono::Utc::now().timestamp() + } + + fn valid_claims() -> Value { + json!({ + "iss": ISSUER, + "sub": SPIFFE_ID, + "aud": [AUDIENCE], + "iat": now(), + "exp": now() + 3600, + }) + } + + /// Overwrite one claim of a claim set built by [`valid_claims`]. + fn set_claim(claims: &mut Value, name: &str, value: Value) { + claims + .as_object_mut() + .expect("object") + .insert(name.to_owned(), value); + } + + fn sign(key: &TestKey, kid: &str, alg: Algorithm, claims: &Value) -> String { + let mut header = Header::new(alg); + header.kid = Some(kid.to_owned()); + encode(&header, claims, &key.encoding_key).expect("sign JWT") + } + + /// Full SVID acceptance decision: signature/issuer/audience/expiry, then the + /// SPIFFE-specific subject and audience requirements. + fn accept(config: &JwtConfig, token: &str) -> crate::result::KResult { + let claim = config.validate_signed_token(token, true)?; + spiffe_authenticated_user(&claim) + } + + #[tokio::test] + async fn valid_svid_is_accepted() { + let key = generate_key(); + let config = config(&key).await; + let token = sign(&key, KID, Algorithm::ES256, &valid_claims()); + let user = accept(&config, &token).expect("valid SVID must be accepted"); + assert_eq!(user.username.as_str(), SPIFFE_ID); + assert_eq!(user.auth_method, AuthMethod::JwtSvid); + } + + /// SVID minted for another service (cross-service replay). + #[tokio::test] + async fn svid_for_another_audience_is_rejected() { + let key = generate_key(); + let config = config(&key).await; + let mut claims = valid_claims(); + set_claim(&mut claims, "aud", json!(["some-other-service"])); + let token = sign(&key, KID, Algorithm::ES256, &claims); + accept(&config, &token).expect_err("wrong audience must be rejected"); + } + + /// `jsonwebtoken` only compares `aud` when the claim is present; the SVID acceptance + /// path must still refuse a token with no audience at all. + #[tokio::test] + async fn svid_without_audience_is_rejected() { + let key = generate_key(); + let config = config(&key).await; + let mut claims = valid_claims(); + claims.as_object_mut().expect("object").remove("aud"); + let token = sign(&key, KID, Algorithm::ES256, &claims); + accept(&config, &token).expect_err("missing audience must be rejected"); + } + + #[tokio::test] + async fn expired_svid_is_rejected() { + let key = generate_key(); + let config = config(&key).await; + let mut claims = valid_claims(); + set_claim(&mut claims, "exp", json!(now() - 3600)); + let token = sign(&key, KID, Algorithm::ES256, &claims); + accept(&config, &token).expect_err("expired SVID must be rejected"); + } + + #[tokio::test] + async fn svid_from_another_issuer_is_rejected() { + let key = generate_key(); + let config = config(&key).await; + let mut claims = valid_claims(); + set_claim(&mut claims, "iss", json!("https://evil.example.org")); + let token = sign(&key, KID, Algorithm::ES256, &claims); + accept(&config, &token).expect_err("wrong issuer must be rejected"); + } + + /// Token claiming the trusted `kid` but signed with a different private key. + #[tokio::test] + async fn svid_with_forged_signature_is_rejected() { + let trusted = generate_key(); + let attacker = generate_key(); + let config = config(&trusted).await; + let token = sign(&attacker, KID, Algorithm::ES256, &valid_claims()); + accept(&config, &token).expect_err("forged signature must be rejected"); + } + + #[tokio::test] + async fn svid_with_unknown_kid_is_rejected() { + let key = generate_key(); + let config = config(&key).await; + let token = sign(&key, "unknown-kid", Algorithm::ES256, &valid_claims()); + accept(&config, &token).expect_err("unknown kid must be rejected"); + } + + /// Algorithm-confusion guard: symmetric algorithms are never accepted. + #[tokio::test] + async fn hs256_svid_is_rejected() { + let key = generate_key(); + let config = config(&key).await; + let mut header = Header::new(Algorithm::HS256); + header.kid = Some(KID.to_owned()); + let token = encode( + &header, + &valid_claims(), + &EncodingKey::from_secret(b"any-secret"), + ) + .expect("sign HS256 JWT"); + accept(&config, &token).expect_err("HS256 must be rejected"); + } +} diff --git a/crate/server/src/middlewares/jwt/mod.rs b/crate/server/src/middlewares/jwt/mod.rs index be530a0d25..45091023c8 100644 --- a/crate/server/src/middlewares/jwt/mod.rs +++ b/crate/server/src/middlewares/jwt/mod.rs @@ -14,3 +14,4 @@ mod jwt_middleware; pub(crate) use jwt_middleware::jwt_auth_middleware; mod jwt_token_auth; +pub(crate) use jwt_token_auth::validate_jwt_svid; diff --git a/crate/server/src/middlewares/mod.rs b/crate/server/src/middlewares/mod.rs index 4fab599c5c..233a9b5d28 100644 --- a/crate/server/src/middlewares/mod.rs +++ b/crate/server/src/middlewares/mod.rs @@ -18,7 +18,9 @@ mod ensure_auth; pub(crate) use ensure_auth::ensure_auth_middleware; mod jwt; -pub(crate) use jwt::{JwksManager, JwtConfig, JwtTokenHeaders, UserClaim, jwt_auth_middleware}; +pub(crate) use jwt::{ + JwksManager, JwtConfig, JwtTokenHeaders, UserClaim, jwt_auth_middleware, validate_jwt_svid, +}; mod rate_limiter; pub(crate) use rate_limiter::{RateLimiterConfig, RateLimiterMiddleware}; @@ -74,6 +76,8 @@ pub(crate) enum AuthMethod { SpireToken, /// Standard OIDC / `IdP` JWT (with `kid`) OidcJwt, + /// SPIFFE JWT-SVID (no `email` claim; identity taken from `sub = spiffe://...`) + JwtSvid, /// Cosmian Auth Verifier JWT (no `kid`) AuthVerifierJwt, /// Static API token (Bearer) diff --git a/crate/server/src/routes/google_cse/jwt.rs b/crate/server/src/routes/google_cse/jwt.rs index 175001ef43..9298e8a905 100644 --- a/crate/server/src/routes/google_cse/jwt.rs +++ b/crate/server/src/routes/google_cse/jwt.rs @@ -76,6 +76,7 @@ pub fn list_jwt_configurations( // calls `Validation::set_audience`, which makes jsonwebtoken 10.x accept // the token instead of rejecting it with InvalidAudience. jwt_audience: Some(vec!["kacls-migration".to_owned()]), + accept_spiffe_subject: false, }) .collect::>() } @@ -105,6 +106,7 @@ fn jwt_authorization_config_application( jwt_issuer_uri, jwks: jwks_manager, jwt_audience, + accept_spiffe_subject: false, }) } @@ -635,6 +637,7 @@ mod tests { jwt_issuer_uri: issuer.to_owned(), jwks: jwks_manager, jwt_audience: Some(vec!["cse-authorization".to_owned()]), + accept_spiffe_subject: false, }); let now = now_usize(); @@ -700,6 +703,7 @@ mod tests { jwt_issuer_uri: issuer.to_owned(), jwks: jwks_manager, jwt_audience: Some(vec!["cse-authorization".to_owned()]), + accept_spiffe_subject: false, }); let now = now_usize(); @@ -736,6 +740,7 @@ mod tests { jwt_issuer_uri: issuer.to_owned(), jwks: jwks_manager, jwt_audience: None, + accept_spiffe_subject: false, }); let now = now_usize(); @@ -773,6 +778,7 @@ mod tests { jwt_issuer_uri: expected_issuer.to_owned(), jwks: jwks_manager, jwt_audience: Some(vec!["cse-authorization".to_owned()]), + accept_spiffe_subject: false, }); let now = now_usize(); @@ -824,6 +830,7 @@ mod tests { jwt_issuer_uri: kms_a_url.to_owned(), jwks: jwks_manager.clone(), jwt_audience: Some(vec!["kacls-migration".to_owned()]), + accept_spiffe_subject: false, }; let cse_config = super::GoogleCseConfig { @@ -878,6 +885,7 @@ mod tests { jwt_issuer_uri: kms_a_url.to_owned(), jwks: jwks_manager.clone(), jwt_audience: Some(vec!["kacls-migration".to_owned()]), + accept_spiffe_subject: false, }; let cse_config = super::GoogleCseConfig { @@ -947,6 +955,7 @@ mod tests { "{},{},{}", JWT_ISSUER_URI, JWKS_URI, client_id )]), + jwt_svid_auth: false, }; let idp_configs = jwt_authentication_config .extract_idp_configs() @@ -956,6 +965,7 @@ mod tests { jwt_issuer_uri: idp_configs[0].jwt_issuer_uri.clone(), jwks: jwks_manager.clone(), jwt_audience: idp_configs[0].jwt_audience.clone(), + accept_spiffe_subject: false, }; let authentication_token = jwt_authentication_config diff --git a/crate/server/src/routes/ui_auth.rs b/crate/server/src/routes/ui_auth.rs index c27ffc837a..79123656b8 100644 --- a/crate/server/src/routes/ui_auth.rs +++ b/crate/server/src/routes/ui_auth.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::{collections::HashMap, sync::Arc}; use actix_session::Session; use actix_web::{HttpRequest, HttpResponse, get, post, web}; @@ -11,7 +11,7 @@ use url::Url; use crate::{ config::{AuthVerifierRuntimeConfig, OidcRuntimeConfig}, - middlewares::{UserId, reject_reserved_aws_xks_identity}, + middlewares::{JwtConfig, UserId, reject_reserved_aws_xks_identity, validate_jwt_svid}, }; fn random_b64url(len_bytes: usize) -> Result { @@ -351,6 +351,12 @@ pub(crate) struct AuthVerifierLoginRequest { totp_code: Option, } +/// Request body for `POST /ui/login_svid`. +#[derive(Debug, Deserialize)] +pub(crate) struct JwtSvidLoginRequest { + jwt_svid: String, +} + /// Mirrors the Auth Verifier server's `AuthenticationResult` shape /// (see `authentication/client/src/models/login.rs`). Duplicated here — rather than /// depending on the `authentication` crate — the same way `ckms login cosmian` @@ -538,6 +544,46 @@ pub(crate) async fn login_as( } } +/// SPIFFE JWT-SVID session login, meant for a gateway / BFF in front of the Web UI. +/// +/// The caller posts `{ "jwt_svid": "" }` with a JWT-SVID minted by SPIRE (Workload +/// API or `spire-server jwt mint`). The token is validated against the SPIFFE-enabled JWT +/// issuers configured via `--jwt-auth-provider` + `--jwt-svid-auth` — the same +/// configuration used for bearer-token API/KMIP authentication — and its `sub` MUST be a +/// `spiffe://` ID (a token that only carries an `email` claim is rejected). On success the +/// `sub` claim (`spiffe:///`) becomes the session's `user_id`, exactly +/// like the OIDC `callback` and Auth Verifier `login_as` flows above. +#[post("/login_svid")] +pub(crate) async fn login_svid( + session: Session, + body: web::Json, + jwt_configurations: web::Data>>, +) -> HttpResponse { + if !jwt_configurations.iter().any(|c| c.accept_spiffe_subject) { + return HttpResponse::InternalServerError().json( + serde_json::json!({ "error": "SPIFFE JWT-SVID login is not enabled on this server" }), + ); + } + + let authenticated = match validate_jwt_svid(&jwt_configurations, body.jwt_svid.trim()).await { + Ok(user) => user, + Err(e) => { + return HttpResponse::Unauthorized() + .json(serde_json::json!({ "error": format!("{e}") })); + } + }; + + // Issue a fresh session ID on login so a session ID planted before authentication + // (session fixation) never becomes an authenticated session. + session.renew(); + if session.insert("user_id", &authenticated.username).is_err() { + return HttpResponse::InternalServerError() + .json(serde_json::json!({ "error": "Failed to store user_id in session" })); + } + + HttpResponse::Ok().json(serde_json::json!({ "next_step": "Authenticated" })) +} + #[get("/whoami")] pub(crate) async fn whoami(session: Session) -> HttpResponse { match session.get::("user_id") { @@ -613,6 +659,7 @@ pub fn configure_auth_routes(cfg: &mut web::ServiceConfig) { cfg.service(login) .service(callback) .service(login_as) + .service(login_svid) .service(whoami) .service(logout) .service(get_auth_method); diff --git a/crate/server/src/start_kms_server.rs b/crate/server/src/start_kms_server.rs index e2ecee241c..553d4b2a67 100644 --- a/crate/server/src/start_kms_server.rs +++ b/crate/server/src/start_kms_server.rs @@ -1022,6 +1022,7 @@ pub async fn prepare_kms_server( jwt_issuer_uri: idp_config.jwt_issuer_uri.clone(), jwks: jwks_manager.clone(), jwt_audience: idp_config.jwt_audience.clone(), + accept_spiffe_subject: kms_server.params.jwt_svid_auth_enabled, }) .collect::>(); @@ -1635,15 +1636,29 @@ pub async fn prepare_kms_server( // Ordered list of UI login methods, highest priority first. The Web UI // renders the first entry as the primary login action and the rest as // secondary actions (a button when a single alternative exists, a - // dropdown when several do). Priority is JWT > AUTH_VERIFIER > CERT: + // dropdown when several do). Priority is JWT > SPIFFE > AUTH_VERIFIER > CERT: // the interactive, per-user methods come before the ambient client // certificate probe. AUTH_VERIFIER is only offered when its UI login is // enabled. The singular `auth_method` served by `get_auth_method` is // derived as the first entry for backward compatibility. + // + // SPIFFE is not an interactive login: the browser session is established by a + // gateway that posts a JWT-SVID to `/ui/login_svid`. It is advertised so the UI + // resolves the identity through `/ui/whoami` instead of reporting that + // authentication is disabled. + let use_spiffe_ui_auth = jwt_configurations + .iter() + .any(|config| config.accept_spiffe_subject); let mut auth_methods: Vec = Vec::new(); - if use_jwt_auth { + // Without a discovered UI OIDC provider the "JWT" login redirect cannot work; on a + // SPIFFE deployment (bearer JWT-SVIDs, no UI OIDC) it would only be a broken + // button, so it is offered only when OIDC is discovered or SPIFFE is not in use. + if use_jwt_auth && (oidc_runtime_config.discovered.is_some() || !use_spiffe_ui_auth) { auth_methods.push("JWT".to_owned()); } + if use_spiffe_ui_auth { + auth_methods.push("SPIFFE".to_owned()); + } if use_auth_verifier && kms_server_for_http .params @@ -1699,6 +1714,7 @@ pub async fn prepare_kms_server( let mut auth_routes = web::scope("/ui") .app_data(Data::new(oidc_runtime_config)) .app_data(Data::new(auth_verifier_runtime_config)) + .app_data(Data::new(jwt_configurations.clone())) .app_data(Data::new(kms_public_url.clone())) .app_data(Data::new(ui_index_folder.clone())) .app_data(Data::new(auth_methods)) diff --git a/crate/server/src/tests/google_cse/mod.rs b/crate/server/src/tests/google_cse/mod.rs index 8762684370..820710f9d4 100644 --- a/crate/server/src/tests/google_cse/mod.rs +++ b/crate/server/src/tests/google_cse/mod.rs @@ -883,6 +883,7 @@ async fn test_google_cse_custom_jwt() -> KResult<()> { jwt_issuer_uri: kacls_url.to_owned(), jwt_audience: Some(vec!["kacls-migration".to_owned()]), jwks: Arc::new(jwks_manager), + accept_spiffe_subject: false, }]), authorization: HashMap::new(), }; @@ -974,6 +975,7 @@ async fn test_google_cse_custom_jwt_multi_audience_match() -> KResult<()> { jwt_issuer_uri: kacls_url.to_owned(), jwt_audience: Some(vec!["wrong-aud".to_owned(), "kacls-migration".to_owned()]), jwks: Arc::new(jwks_manager), + accept_spiffe_subject: false, }]), authorization: HashMap::new(), }; @@ -1063,6 +1065,7 @@ async fn test_google_cse_custom_jwt_multi_audience_nomatch() -> KResult<()> { jwt_issuer_uri: kacls_url.to_owned(), jwt_audience: Some(vec!["wrong1".to_owned(), "wrong2".to_owned()]), jwks: Arc::new(jwks_manager), + accept_spiffe_subject: false, }]), authorization: HashMap::new(), }; diff --git a/crate/server/src/tests/google_cse/utils.rs b/crate/server/src/tests/google_cse/utils.rs index 5067b4df72..1b91beb870 100644 --- a/crate/server/src/tests/google_cse/utils.rs +++ b/crate/server/src/tests/google_cse/utils.rs @@ -72,6 +72,7 @@ pub(crate) async fn google_cse_auth( jwt_issuer_uri: GOOGLE_JWT_ISSUER_URI.to_owned(), jwks: jwks_manager.clone(), jwt_audience: None, + accept_spiffe_subject: false, }; Ok(GoogleCseConfig { diff --git a/documentation/docs/SUMMARY.md b/documentation/docs/SUMMARY.md index d6bdb7b132..119edf82f6 100644 --- a/documentation/docs/SUMMARY.md +++ b/documentation/docs/SUMMARY.md @@ -85,6 +85,8 @@ - [SPIRE / SPIFFE (Zero-Trust M2M)]() - [Vault-compatible integration](integrations/spire_spiffe.md) - [Native KMIP 2.1 plugins](integrations/spire_spiffe_kmip_plugin.md) + - [Web UI gateway SPIFFE authentication](integrations/spire_webui.md) + - [CLI (ckms) SPIFFE authentication](integrations/spire_ckms.md) - [Installation]() - [Getting started](installation/installation_getting_started.md) - [Deploying in a Cosmian Confidential VM](installation/marketplace_guide.md) diff --git a/documentation/docs/adr/2026-09-19-spiffe-jwt-svid-authentication.md b/documentation/docs/adr/2026-09-19-spiffe-jwt-svid-authentication.md new file mode 100644 index 0000000000..06b9ba1548 --- /dev/null +++ b/documentation/docs/adr/2026-09-19-spiffe-jwt-svid-authentication.md @@ -0,0 +1,185 @@ +--- +title: "ADR-2026-09-19: SPIFFE JWT-SVID Authentication via JWT `sub`-Claim Fallback" +status: "Accepted" +date: "2026-09-19" +authors: "Architecture Team" +tags: ["architecture", "spiffe", "spire", "jwt", "mtls", "authentication"] +supersedes: "" +superseded_by: "" +--- + +# ADR-2026-09-19: SPIFFE JWT-SVID Authentication via JWT `sub`-Claim Fallback + +## Status + +Accepted + +## Context + +Operators deploying the KMS inside a Kubernetes cluster that uses SPIFFE/SPIRE for +workload identity terminate service-to-service traffic in mTLS, with each workload +authenticating via a JWT-SVID (a JWT issued by the SPIRE OIDC Discovery Provider) rather +than a classic OIDC identity token. + +Two options were initially considered by the operator to reach this deployment shape: + +1. Enable KMS mTLS **and** force `force_default_username = admin` in the server config so + that every authenticated client (whatever its actual workload identity) is mapped to a + single administrative account. This defeats per-workload authorization/audit and is a + security regression. +2. Enable TLS only, with no authentication at all, which removes any application-level + identity and authorization — unacceptable for a KMS. + +Investigation of the existing KMS auth stack showed two real gaps preventing a proper +third option (mTLS as transport only + JWT-SVID as the actual identity): + +- `crate/server/src/middlewares/jwt/jwt_token_auth.rs` required an `email` claim to + authenticate a JWT. A JWT-SVID carries no `email` claim — only `sub = + spiffe:///` (already required and validated by + `validate_authentication_token`, which enforces `required_spec_claims = ["sub", "exp"]`). +- The existing mTLS middleware (`tls_auth.rs`) only reads the certificate CN, not the + SPIFFE SAN URI — but this ADR does not extend it (see Alternatives). + +The KMS already supports configuring an arbitrary OIDC-style JWT issuer via +`--jwt-auth-provider`, and a SPIRE OIDC Discovery Provider exposes a standard +`/.well-known/openid-configuration` + JWKS endpoint, so no new provider integration is +needed — only the claim-to-identity mapping logic needed to change, and only when the +operator explicitly opts in. + +## Decision + +Introduce an explicit, per-deployment opt-in flag, `--jwt-svid-auth` / +`KMS_JWT_SVID_AUTH` (`IdpAuthConfig.jwt_svid_auth`), applied globally to all +`--jwt-auth-provider` entries. When enabled, the JWT authentication middleware accepts a +validated token that has **no** `email` claim, provided its `sub` claim starts with +`spiffe://` (a SPIFFE ID). The full SPIFFE URI is used, unmodified, as the KMS `UserId` +(no truncation), which becomes the acting principal for every subsequent authorization +check. + +**Audience is mandatory.** The SPIFFE JWT-SVID specification requires a validator to +reject any SVID whose `aud` does not include the validator's own identifier; otherwise a +token minted for service A can be replayed against service B. `jsonwebtoken` only checks +`aud` when the claim is present, so the SPIFFE `sub` path (Bearer and `POST /ui/login_svid`) +additionally requires a present, non-empty `aud` claim, and the server **refuses to start** +when `--jwt-svid-auth` is set and any `--jwt-auth-provider` entry has no audience (third +field `issuer,jwks_uri,audience`). Because the flag is global, this applies to every +configured provider. Google CSE issuers never accept SPIFFE subjects. + +**mTLS precedence.** `tls_auth.rs` is left unmodified and does not read the SPIFFE SAN, but +mTLS is *not* purely transport-level when combined with JWT-SVID. Actix runs `wrap`ped +middleware last-in-first-out, and `start_kms_server.rs` wraps the client-certificate +middleware after the JWT middleware, so it runs **first**. If the presented client +certificate has a CN, the request is authenticated as that CN (`AuthMethod::Mtls`); the JWT +middleware then sees an already-authenticated user and ignores any JWT-SVID, as does the +session cookie. A certificate without a usable CN (missing, empty or `*`) does not +authenticate, and the request falls through to the JWT-SVID. Operators wanting the SPIFFE ID +to be the identity must therefore issue CN-less client certificates (SPIRE-issued X.509-SVIDs +typically carry no CN; verify for your SPIRE version), use separate listeners/paths, or knowingly accept CN precedence. + +Priority order in the JWT middleware is: `email` (existing OIDC/IdP behavior, unchanged) +first; `sub` (SPIFFE-only fallback, opt-in) second; otherwise reject with the pre-existing +"no email in JWT" error. This preserves 100% backward compatibility for every existing +JWT/OIDC and Google CSE configuration, none of which set the new flag. + +The Web UI and `ckms` CLI are fully integrated with SPIFFE JWT-SVID authentication: + +- **`ckms` CLI**: Implements native Workload API gRPC integration via `ckms login spire --audience ` (`crate/clients/clap/src/actions/login.rs`), which connects directly to the local SPIRE Agent's Unix Domain Socket (discovered via `SPIFFE_ENDPOINT_SOCKET` or `--socket-path`), attests the process via kernel peer credentials (`SO_PEERCRED`), fetches a JWT-SVID, and persists it into `http_config.access_token` in `ckms.toml`. +- **Web UI (Gateway/BFF session)**: Browsers cannot reach the Workload API, and the Web UI has **no** paste-your-SVID login form. When `--jwt-svid-auth` is set the server advertises `"SPIFFE"` in the `auth_methods` of `GET /ui/auth_method` (priority JWT > SPIFFE > AUTH_VERIFIER > CERT). A trusted gateway/BFF posts a JWT-SVID to `POST /ui/login_svid` (`{"jwt_svid":""}`), which establishes an encrypted, cookie-backed `auth_session`; the UI resolves the identity via `GET /ui/whoami`. A browser with no session only sees an informational notice. The gateway MUST authenticate the end user first (see NEG-004). +- **Is `jwt_svid_auth` still mandatory?**: **Yes, `jwt_svid_auth` remains mandatory on the KMS server.** It acts as an indispensable explicit security gate. Without this flag, a standard OIDC provider could allow tokens with missing or forged `email` claims to fall back to arbitrary `sub` identities, violating standard OIDC trust invariants. Furthermore, both `validate_jwt_svid` (used only by `POST /ui/login_svid`) and the bearer path (`jwt_auth_middleware` → `handle_jwt` → `resolve_authenticated_user`) enforce `accept_spiffe_subject == true` before permitting `sub: spiffe://...` resolution. + +## Consequences + +### Positive + +- **POS-001**: Operators can run the KMS as a proper SPIFFE-aware workload — mTLS for + transport-level trust, JWT-SVID for per-workload identity and authorization — without + collapsing all traffic onto a single shared `admin` account. +- **POS-002**: Zero behavioral change for existing OIDC/IdP and Google CSE deployments: the + fallback is strictly opt-in per flag and additionally scoped to `sub` values that are + syntactically SPIFFE IDs. +- **POS-003**: No new provider/protocol integration was required — the SPIRE OIDC + Discovery Provider is consumed through the existing generic `--jwt-auth-provider` + mechanism. +- **POS-004**: `ckms` CLI natively supports acquiring and using SPIFFE JWT-SVIDs via `ckms login spire`, operating over standard Unix domain sockets without shelling out to external binaries or requiring long-lived credentials. +- **POS-005**: The Web UI can be fronted by a gateway/BFF that establishes a cookie-backed session through `POST /ui/login_svid`, so raw JWT-SVIDs never reach the browser. No credential is ever typed or pasted into the UI. + +### Negative + +- **NEG-001**: The full SPIFFE URI becomes the KMS username; operators relying on + human-readable usernames for audit/reporting will see SPIFFE URIs instead (mitigated by + documenting this mapping clearly; no truncation/aliasing is performed, by design, to + avoid silently colliding two distinct workload identities). +- **NEG-002**: Direct in-browser attestation against the SPIRE Workload API remains architecturally impossible due to browser sandbox constraints; Web UI sessions require a gateway (or sidecar) with access to the Workload API socket to bridge into `POST /ui/login_svid`. +- **NEG-004**: A session obtained with the *gateway's own* JWT-SVID is a session as one shared SPIFFE identity: every browser that receives it shares one user, with no per-user authorization or audit, which is the anti-pattern rejected in ALT-003/ALT-004. The gateway must authenticate the end user (e.g. OIDC at the gateway) before relaying a session, and the session identity should be scoped accordingly. +- **NEG-005**: When mTLS (`clients_ca_cert_file`) and `--jwt-svid-auth` are both enabled, a client certificate with a CN takes precedence over any JWT-SVID or session cookie (see Decision). +- **NEG-006**: Operators must configure an audience on every `--jwt-auth-provider` entry when enabling `--jwt-svid-auth`; the server refuses to start otherwise. +- **NEG-003**: The JWT middleware now carries an additional branch (SPIFFE `sub` fallback), + slightly increasing its cyclomatic complexity; mitigated by extracting the decision logic + into a small, independently unit-tested pure function + (`resolve_authenticated_user`). + +## Alternatives Considered + +### Extend `tls_auth.rs` to read the SPIFFE SAN URI from the client certificate + +- **ALT-001 Description**: Have the mTLS middleware itself extract the SPIFFE ID from the + certificate's SAN URI and use it as the KMS identity, instead of relying on the JWT. +- **ALT-002 Rejection Reason**: The operator's deployment explicitly separates transport + trust (mTLS) from application identity (JWT-SVID), matching how the SPIRE Workload API + and the cluster's existing token-minting flow are actually used. Using the certificate as + the identity source would create two divergent identity paths (cert-based vs + JWT-based) depending on which auth method wins, complicating the audit trail. Kept as a + documented non-goal. + +### Force `force_default_username = admin` when mTLS is enabled + +- **ALT-003 Description**: Map every mTLS-authenticated client to a single shared `admin` + account, as the operator was initially forced to do. +- **ALT-004 Rejection Reason**: Eliminates per-workload authorization and audit trail — + unacceptable from a least-privilege and traceability standpoint; the entire motivation + for this ADR was to avoid this workaround. + +### TLS only, no authentication + +- **ALT-005 Description**: Terminate TLS without any authentication middleware. +- **ALT-006 Rejection Reason**: Removes all application-level identity; not viable for a + KMS handling key material and cryptographic operations. + +## Implementation Notes + +- **IMP-001**: New `AuthMethod::JwtSvid` variant distinguishes SPIFFE JWT-SVID + authentication from standard OIDC JWT (`AuthMethod::OidcJwt`) in audit logs. +- **IMP-002**: `JwtConfig.accept_spiffe_subject: bool` is set from the single global + `--jwt-svid-auth` flag on every `--jwt-auth-provider`-derived entry (there is no + per-provider switch); Google CSE's internally constructed `JwtConfig` entries always set + it to `false`. Startup fails if any such entry lacks an audience, and the SPIFFE `sub` + path requires a non-empty `aud` claim (SPIFFE JWT-SVID spec: validators must reject SVIDs + whose `aud` does not include their own identifier, preventing cross-service replay). +- **IMP-003**: `resolve_authenticated_user` (pure function, no HTTP/actix dependency) holds + the identity-resolution decision and is covered by unit tests in + `jwt_token_auth.rs` (email priority, SPIFFE acceptance/rejection, non-SPIFFE + `sub` rejection, reserved-identity defense-in-depth). +- **IMP-004**: Integration test harnesses (`.mise/tasks/test/spire-jwt-svid` in this repository, as well as the reference Kubernetes deployment smoke test) validate the end-to-end flow: + 1. Native `ckms login spire --audience ` connects to the SPIRE Agent Workload API socket and persists the JWT-SVID. + 2. A gateway/BFF calls `POST /ui/login_svid` to obtain a cookie-backed session. + 3. Authenticated requests create KMS cryptographic keys owned by the full SPIFFE URI. +- **IMP-005**: The Web UI backend exposes `POST /ui/login_svid` (`crate/server/src/routes/ui_auth.rs`), which requires `--jwt-svid-auth` (HTTP 500 otherwise), accepts only tokens whose `sub` starts with `spiffe://` (a token with an `email` claim but a non-SPIFFE `sub` is rejected), and validates the JWT-SVID against the cached JWKS, refreshing the JWKS once and retrying on failure (SPIRE key rotation). Success returns 200 `{"next_step":"Authenticated"}` and sets the `auth_session` cookie; an invalid SVID returns 401. `GET /ui/auth_method` advertises `SPIFFE` when the flag is set. +- **IMP-006**: Wizard (`auth_wizard.rs`) prompts operators configuring a JWT/OIDC provider whether it issues SPIFFE JWT-SVIDs, populating `jwt_svid_auth` accordingly. + +## References + +- **REF-001**: `documentation/docs/adr/2026-07-26-spire-spiffe-via-vault-api.md` — related + but distinct SPIRE/SPIFFE integration (KMS as Vault-compatible backend *for* SPIRE + itself, not KMS-as-a-SPIFFE-workload authentication). +- **REF-002**: `documentation/docs/integrations/spire_webui.md` — Web UI SPIFFE Authentication & Ingress Gateway Architecture. +- **REF-003**: `documentation/docs/integrations/spire_ckms.md` — CLI (`ckms`) SPIFFE Authentication Architecture. +- **REF-004**: `crate/server/src/middlewares/jwt/jwt_token_auth.rs`, + `crate/server/src/middlewares/jwt/jwt_config.rs`, + `crate/server/src/routes/ui_auth.rs`, + `crate/clients/clap/src/actions/login.rs`, + `crate/server/src/config/command_line/idp_auth_config.rs`, + `crate/server/src/config/params/server_params.rs`, + `crate/server/src/start_kms_server.rs`, + `crate/server/src/config/wizard/auth_wizard.rs`. +- **REF-005**: SPIFFE JWT-SVID specification — + diff --git a/documentation/docs/configuration/log-reference.md b/documentation/docs/configuration/log-reference.md index 5ae150ee38..532f59c9d5 100644 --- a/documentation/docs/configuration/log-reference.md +++ b/documentation/docs/configuration/log-reference.md @@ -58,7 +58,7 @@ Crate path: `crate/server` | `warn` | `UI folder invalid or Linux default detected, falling back to: {fallback:#?}` | `src/config/params/server_params.rs` | `fallback`: fallback UI folder path | - | | `warn` | `{:?} {} 401 unauthorized, no email in JWT` | `src/middlewares/jwt/jwt_token_auth.rs` | - | - | | `warn` | `{:?} {} 401 unauthorized: bad JWT` | `src/middlewares/jwt/jwt_token_auth.rs` | - | - | -| `warn` | `{error:?}` | `src/middlewares/jwt/jwt_token_auth.rs` | `error`: error detail | - | +| `warn` | `{error:?}` | `src/middlewares/jwt/jwt_token_auth.rs` | `error`: error detail | ×2 in this file | | `warn` | `{status_code} - {message}` | `src/routes/mod.rs` | `status_code`: HTTP status code
`message`: human-readable message text | - | | `warn` | `{status} - {}` | `src/routes/jose/error.rs` | `status`: HTTP response status | - | | `info` | `AUTHENTICATION token: {:?}` | `src/routes/google_cse/jwt.rs` | - | - | @@ -746,10 +746,14 @@ Crate path: `crate/server` | `error` | `AuditFileStore: cannot acquire audit log lock {} ({e}) — retrying` | `src/core/audit/file_store.rs` | `e` | - | | `trace` | `Extractable: {:?}` | `src/core/operations/attributes/add.rs` | - | - | | `trace` | `Set Attribute: Extractable: {:?}` | `src/core/operations/attributes/set.rs` | - | - | +| `warn` | `no email in JWT` | `src/middlewares/jwt/jwt_token_auth.rs` | - | Emitted when a validated JWT has no email claim and `--jwt-svid-auth` is not enabled or sub is not a valid SPIFFE ID | +| `debug` | `JWT-SVID access granted to {sub}!` | `src/middlewares/jwt/jwt_token_auth.rs` | `sub`: SPIFFE ID (URI) from JWT sub claim | Workload authenticated via SPIFFE JWT-SVID with full URI mapped to KMS UserId | | `debug` | `DeriveKey asymmetric operation completed successfully` | `src/core/operations/derive_key.rs` | - | Emitted after a non-FIPS X25519 ECDH `DeriveKey` request has validated both referenced keys, derived the shared secret, and persisted the resulting `SecretData` object. | | `warn` | `[kms-init] Failed to seed kms.keys.active.count: {e}` | `src/core/kms/mod.rs` | `e` | - | | `warn` | `[metrics-cron] Failed to sync kms.keys.active.count: {}` | `src/cron.rs` | - | - | | `warn` | `JWK serialization failed uid={uid}: {e}` | `src/routes/jwks.rs` | `uid`: unique identifier of the key object being published in the JWKS; `e`: the `serde_json` serialization error | Emitted when a typed `Jwk` fails to serialize to JSON; the affected key is skipped and omitted from the JWKS `keys` array rather than emitting an invalid `null` entry. | +| `warn` | `JWKS refresh failed while validating a JWT-SVID: {error:?}` | `src/middlewares/jwt/jwt_token_auth.rs` | `error`: error detail | JWKS endpoint unreachable | +| `warn` | `JWT-SVID for {sub} rejected: missing or empty 'aud' claim` | `src/middlewares/jwt/jwt_token_auth.rs` | `sub`: SPIFFE ID of the rejected SVID | Possible cross-service SVID replay attempt | ### `cosmian_kms_server_database` diff --git a/documentation/docs/configuration/server_cli.md b/documentation/docs/configuration/server_cli.md index 22bd959381..3b39574c1d 100644 --- a/documentation/docs/configuration/server_cli.md +++ b/documentation/docs/configuration/server_cli.md @@ -372,6 +372,17 @@ Options: [env: KMS_JWT_AUTH_PROVIDER=] + --jwt-svid-auth + Accept SPIFFE JWT-SVIDs from the configured `--jwt-auth-provider` issuers. + + A SPIFFE JWT-SVID carries no `email` claim, only a `sub` claim shaped as `spiffe:///`. When this flag is enabled, a JWT that validates successfully (signature, issuer, audience, expiry) against a configured issuer but has no `email` claim is authenticated using its `sub` claim **only if** `sub` starts with `spiffe://`; every other JWT still requires `email` as before. + + The flag is global: it applies to every `--jwt-auth-provider`. Every provider MUST specify an audience (`issuer,jwks_uri,audience`), and the SVID MUST carry a matching `aud` claim; otherwise the server refuses to start, since an SVID minted for another service could be replayed against the KMS. + + Disabled by default: enabling it only makes sense when the configured issuer(s) are a SPIFFE-aware JWKS source (e.g. a SPIRE OIDC Discovery Provider). + + [env: KMS_JWT_SVID_AUTH=] + --enable Disable the embedded web UI. When set to false, the UI HTML assets are not served and all `/ui/` routes return 404 diff --git a/documentation/docs/configuration/server_configuration_file.md b/documentation/docs/configuration/server_configuration_file.md index 2d597e232e..9225a36bec 100644 --- a/documentation/docs/configuration/server_configuration_file.md +++ b/documentation/docs/configuration/server_configuration_file.md @@ -331,6 +331,15 @@ hostname = "0.0.0.0" # "https://keycloak.example.com/auth/realms/myrealm,," # ] +# Accept SPIFFE JWT-SVIDs from the configured `--jwt-auth-provider` issuers. +# +# A SPIFFE JWT-SVID carries no `email` claim, only a `sub` claim shaped as `spiffe:///`. When this flag is enabled, a JWT that validates successfully (signature, issuer, audience, expiry) against a configured issuer but has no `email` claim is authenticated using its `sub` claim **only if** `sub` starts with `spiffe://`; every other JWT still requires `email` as before. +# +# The flag is global: it applies to every `--jwt-auth-provider`. Every provider MUST specify an audience (`issuer,jwks_uri,audience`), and the SVID MUST carry a matching `aud` claim; otherwise the server refuses to start, since an SVID minted for another service could be replayed against the KMS. +# +# Disabled by default: enabling it only makes sense when the configured issuer(s) are a SPIFFE-aware JWKS source (e.g. a SPIRE OIDC Discovery Provider). +# jwt_svid_auth = false + [workspace] # The root folder where the KMS will store its data A relative path is taken relative to the user's HOME directory # root_data_path = "./cosmian-kms" diff --git a/documentation/docs/integrations/spire_ckms.md b/documentation/docs/integrations/spire_ckms.md new file mode 100644 index 0000000000..ccc7e2f283 --- /dev/null +++ b/documentation/docs/integrations/spire_ckms.md @@ -0,0 +1,165 @@ +# CLI (`ckms`) SPIFFE Authentication Architecture + +Eviden KMS supports native zero-trust workload identity for the CLI (`ckms`) through **SPIFFE** (Secure Production Identity Framework For Everyone) and the **SPIRE Agent Workload API**. + +With `ckms login spire`, workloads, automated pipelines, and operators running alongside a SPIRE Agent can authenticate directly against the local agent's Workload API over a Unix Domain Socket (gRPC) to acquire a SPIFFE JWT-SVID, saving it into the CLI configuration without requiring external binaries (`spire-agent`), long-lived API keys, or hardcoded passwords. + +--- + +## Prerequisites + +- A KMS server started with `--jwt-svid-auth` and at least one `--jwt-auth-provider ",,"` pointing at the SPIRE OIDC Discovery Provider. **The audience is mandatory**: the server refuses to start without it, and rejects SPIFFE JWT-SVIDs whose `aud` is missing or empty. +- A SPIRE Agent reachable from the host running `ckms`, with a workload entry matching the `ckms` process. +- `ckms login spire --audience` MUST use the audience configured on the KMS server. + +--- + +## Architecture Overview + +```mermaid +flowchart LR + subgraph Host["Workload Host / Container Environment"] + CKMS["ckms CLI
(ckms login spire)"] + Conf[("ckms.toml
(access_token)")] + Agent["SPIRE Agent
(Workload API)"] + Socket[("Unix Domain Socket
/tmp/spire-agent/public/api.sock")] + end + + subgraph SPIRE["SPIRE Infrastructure"] + Server["SPIRE Server
(Trust Domain CA)"] + OIDC["SPIRE OIDC
Discovery Provider"] + end + + subgraph KMS["Eviden KMS Server"] + KMSEndpoint["KMS Server API
(REST / KMIP)"] + end + + CKMS -->|Workload API gRPC| Socket + Socket --> Agent + Agent <-->|Node & Workload Sync| Server + CKMS -->|Persist JWT-SVID| Conf + Conf -.->|Read Bearer Token| CKMS + CKMS -->|Authorization: Bearer JWT-SVID| KMSEndpoint + KMSEndpoint -->|JWKS Key Verification| OIDC +``` + +--- + +## Detailed Sequence Flow: Workload Attestation & JWT-SVID Transport + +The authentication lifecycle consists of three distinct phases: Workload API connection and local attestation, token persistence, and subsequent authenticated KMS operations. + +```mermaid +sequenceDiagram + autonumber + participant CLI as ckms CLI + participant Conf as ckms.toml + participant Agent as SPIRE Agent (Workload API) + participant OIDC as SPIRE OIDC Discovery Provider + participant KMS as Eviden KMS Server + + %% Phase 1: Local Workload Attestation & JWT-SVID Fetch + Note over CLI,Agent: Phase 1: Native Workload API Call & Peer Attestation + CLI->>Agent: Connect via SPIFFE_ENDPOINT_SOCKET (UNIX Domain Socket) + Note over CLI,Agent: Kernel verifies peer credentials (SO_PEERCRED: UID/GID/PID) + Agent->>Agent: Workload Attestor matches selectors (e.g., unix:uid) + CLI->>Agent: gRPC FetchJWTSVIDRequest(audience: [kms-audience], spiffe_id: [optional]) + Agent-->>CLI: FetchJWTSVIDResponse(JWT-SVID token) + + %% Phase 2: Configuration Persistence + Note over CLI,Conf: Phase 2: Configuration Storage + CLI->>Conf: Store token in http_config.access_token (clear vault_token) + CLI-->>CLI: Print success message + + %% Phase 3: Authenticated KMS Operations + Note over CLI,KMS: Phase 3: Bearer-Authenticated KMS API Calls + CLI->>KMS: POST /kmip/2_1 (Authorization: Bearer ) + critical JWT-SVID Verification (jwt_auth_middleware, handle_jwt, resolve_authenticated_user) + KMS->>OIDC: Fetch JWKS public keys (cached in memory) + OIDC-->>KMS: RSA / EC verification keys + KMS->>KMS: Validate signature, issuer, expiry, non-empty audience, and subject SPIFFE ID + end + KMS-->>CLI: 200 OK (Key Created / Operation Response) +``` + +--- + +## Security & Architectural Invariants + +### 1. Pure Native Workload API Transport + +- **Protocol**: Standard SPIFFE Workload API over gRPC on a local Unix Domain Socket (UDS). +- **Socket Resolution**: Taken from `--socket-path` when given (a bare absolute path such as `/run/spire/agent.sock`, or a `unix:///path` / `tcp://host:port` URI; bare paths get `unix://` prepended). When the flag is omitted, the standard `SPIFFE_ENDPOINT_SOCKET` environment variable is read (e.g. `unix:///tmp/spire-agent/public/api.sock`). +- **No External Binary Dependency**: Implemented using the pure-Rust `spiffe` crate (`WorkloadApiClient`). Does not shell out to the `spire-agent` CLI binary. +- **Kernel-Enforced Peer Attestation**: The SPIRE Agent attests the calling `ckms` process using kernel peer credentials (`SO_PEERCRED` on Linux, `LOCAL_PEERCRED` on macOS/BSD). Workload entries can restrict authorization by UID, GID, path, or container metadata. + +### 2. Application Identity & Bearer Transport + +- **Token Type**: SPIFFE JWT-SVID conforming to the SPIFFE specification. + - `sub`: Workload SPIFFE ID (`spiffe:///`). + - `aud`: Configured audience matching KMS server expectations. +- **Client Configuration Storage**: + - Saved as `http_config.access_token` in `ckms.toml`. + - Clears any conflicting Vault tokens (`http_config.vault_token = None`). + - Seamlessly consumed by subsequent `ckms` subcommands (`ckms sym ...`, `ckms rsa ...`, `ckms kmip ...`). +- **Server-Side Validation**: + - Verified by the Actix-web `jwt_auth_middleware` (`handle_jwt` → `resolve_authenticated_user`). + - The token MUST carry a non-empty `aud`; the server rejects SPIFFE subjects without one (SPIFFE JWT-SVID spec: validators reject SVIDs not addressed to them, preventing cross-service replay). + - When `clients_ca_cert_file` (mTLS) is also enabled, a client certificate with a CN is authenticated first and takes precedence over the bearer token. + - Cryptographically checked against the SPIRE OIDC Discovery Provider JWKS endpoint. + - Flags: Requires `--jwt-auth-provider=",,"` and `--jwt-svid-auth`. `--jwt-svid-auth` is global (all providers) and the server refuses to start if any provider has no audience. + +--- + +## CLI Usage Reference + +### Command Syntax + +```bash +ckms login spire --audience [OPTIONS] +``` + +### Options + +| Flag | Environment Variable | Required | Description | +|---|---|---|---| +| `--audience` | — | **Yes** | The expected JWT audience. Must match the audience configured in KMS `--jwt-auth-provider` (the third field is mandatory when `--jwt-svid-auth` is set). | +| `--spiffe-id` | — | No | Specific SPIFFE ID to request if the agent serves multiple identities to this workload. | +| `--socket-path` | — (no clap env binding) | No | Bare absolute path (`/run/spire/agent.sock`) or `unix:///path` / `tcp://host:port` URI of the Workload API socket. When omitted, `SPIFFE_ENDPOINT_SOCKET` is read from the environment. | + +### Example Workflow + +```bash +# 1. Export the standard SPIFFE Workload API socket +export SPIFFE_ENDPOINT_SOCKET="unix:///tmp/spire-agent/public/api.sock" + +# 2. Authenticate against the local SPIRE Agent +ckms login spire --audience dawn-kms + +# 3. Perform cryptographic operations with the issued JWT-SVID +ckms sym keys create my-app-key +echo "Secret message" > message.txt +ckms sym encrypt --key-id my-app-key --output-file message.enc message.txt +``` + +--- + +## Server Configuration Reference + +In `kms.toml`: + +```toml +[idp_auth] +jwt_auth_provider = [ + "https://spire-oidc.spire.svc.cluster.local:8443,https://spire-oidc.spire.svc.cluster.local:8443/keys,dawn-kms" +] +jwt_svid_auth = true +``` + +Or via server command-line flags: + +```bash +cosmian_kms_server \ + --jwt-auth-provider "https://spire-oidc.spire.svc.cluster.local:8443,https://spire-oidc.spire.svc.cluster.local:8443/keys,dawn-kms" \ + --jwt-svid-auth +``` diff --git a/documentation/docs/integrations/spire_spiffe.md b/documentation/docs/integrations/spire_spiffe.md index 755ab244d6..93509f5eae 100644 --- a/documentation/docs/integrations/spire_spiffe.md +++ b/documentation/docs/integrations/spire_spiffe.md @@ -1456,3 +1456,131 @@ numbered scenario; all are asserted **live** against a running KMS + auth-verifi - `test_data/spire/setup/kms_setup.sh` — Bash script that runs all provisioning steps in one shot (`ROLE_NAME=my-spire bash test_data/spire/setup/kms_setup.sh`). - `crate/server/documentation/openapi.yaml` — OpenAPI schema for the `/v1/transit/*` and `/v1//*` paths. - `ckms vault approle --help` — full CLI reference for AppRole provisioning. + +--- + +## Workload Authentication via SPIFFE JWT-SVID + +In addition to acting as a Vault-compatible backend for SPIRE itself (described above), +Eviden KMS natively supports **authenticating application workloads using SPIFFE JWT-SVIDs**. + +Workloads running in Kubernetes clusters or bare-metal environments with SPIRE can authenticate +to KMS endpoints using standard bearer token semantics (`Authorization: Bearer `). + +### Operator Decision Tree + +```text +Do you want workload authentication via SPIFFE? +├── Option A: mTLS Client Certificate Authentication +│ ├── Uses X.509 SVID (spire-agent / Envoy mTLS) +│ └── Identity is mapped from certificate Common Name (CN). +├── Option B: JWT-SVID Bearer Authentication +│ ├── Uses SPIRE OIDC Discovery Provider / JWKS endpoint +│ ├── Identity is mapped from `sub` claim (`spiffe:///`) +│ └── Requires `jwt_svid_auth = true` (--jwt-svid-auth). +└── Option C: Dual Layer (mTLS Transport + JWT-SVID Application) + ├── Server configures both `[tls] clients_ca_cert_file` and `[idp_auth] jwt_svid_auth` + ├── Client-certificate auth runs BEFORE the JWT middleware: a client cert with a CN + │ authenticates as that CN and any JWT-SVID / session cookie is ignored. + └── For the SPIFFE ID to be the identity, use client certs without a CN (or separate + listeners); otherwise expect CN precedence. +``` + +### Architecture & Claim Mapping + +- **Standard OIDC vs. SPIFFE JWT-SVID**: Standard OIDC/IdP tokens carry an `email` claim. SPIFFE JWT-SVIDs contain no `email` claim; they identify workloads via `sub = spiffe:///`. +- **Opt-In Flag (`--jwt-svid-auth` / `jwt_svid_auth = true`)**: When enabled, the KMS JWT authentication middleware accepts tokens with no `email` claim provided `sub` begins with `spiffe://`. The full SPIFFE URI is used as the KMS `UserId` / object owner. The flag is **global**: it applies to all `--jwt-auth-provider` entries (there is no per-provider setting). +- **Audience is mandatory**: with `--jwt-svid-auth`, the server refuses to start if any `--jwt-auth-provider` entry has no audience (`issuer,jwks_uri,audience`), and a SPIFFE `sub` token without a non-empty `aud` claim is rejected. This follows the SPIFFE JWT-SVID specification (validators must reject SVIDs not addressed to them) and prevents cross-service replay. + +### KMS Server Configuration + +#### 1. JWT-SVID Only + +In `kms.toml`: + +```toml +[idp_auth] +jwt_auth_provider = [ + "https://oidc-discovery.spire.local,https://oidc-discovery.spire.local/keys,cosmian-kms" +] +jwt_svid_auth = true +``` + +#### 2. Dual Configuration (mTLS + JWT-SVID) + +> **Precedence**: actix runs wrapped middleware last-in-first-out, and the client-certificate +> middleware runs **before** the JWT middleware. A client certificate with a CN authenticates +> the request as that CN (`AuthMethod::Mtls`); the JWT-SVID (and any session cookie) is then +> ignored. A certificate without a usable CN falls through to the JWT-SVID. To make the SPIFFE +> ID the identity, present CN-less client certificates or use separate listeners. + +In `kms.toml`: + +```toml +[tls] +tls_cert_file = "/etc/kms/kms.crt" +tls_key_file = "/etc/kms/kms.key" +clients_ca_cert_file = "/etc/kms/spire-ca.crt" + +[idp_auth] +jwt_auth_provider = [ + "https://oidc-discovery.spire.local,https://oidc-discovery.spire.local/keys,cosmian-kms" +] +jwt_svid_auth = true +``` + +### Workload CLI Usage (`ckms`) + +1. Mint a JWT-SVID for the workload using SPIRE: + + ```bash + spire-server jwt mint \ + -spiffeID spiffe://cosmian-test-a.local/my-workload \ + -audience cosmian-kms + ``` + +2. Configure `ckms.toml` to use the minted token: + + ```toml + [http_config] + server_url = "https://kms.example.com:9998" + access_token = "" + ``` + +3. Run `ckms` commands — all created keys and accesses are owned by `spiffe://cosmian-test-a.local/my-workload`: + + ```bash + ckms sym keys create my-key + ckms access-rights owned + ``` + +### HTTP / REST API Usage + +Workloads can authenticate directly to KMIP or REST endpoints via `Authorization: Bearer `: + +```bash +# Check authenticated identity +curl -k -H "Authorization: Bearer ${JWT_SVID}" https://kms.example.com:9998/me + +# Response: +# {"user":"spiffe://cosmian-test-a.local/my-workload"} +``` + +### Expected Log Messages + +- When JWT-SVID authentication succeeds: + + ```text + [DEBUG] cosmian_kms_server::middlewares::jwt::jwt_token_auth: JWT-SVID access granted to spiffe://cosmian-test-a.local/my-workload! + ``` + +- When client certificate authentication succeeds: + + ```text + [TRACE] cosmian_kms_server::middlewares::tls_auth: Client certificate common name: spire-client + ``` + +> **Note on Web UI**: With `--jwt-svid-auth` the server advertises `SPIFFE` in `/ui/auth_method` +> and accepts a JWT-SVID posted by a trusted gateway to `POST /ui/login_svid`. There is no +> login form. See [Web UI SPIFFE authentication](spire_webui.md), including the shared-identity +> caveat. diff --git a/documentation/docs/integrations/spire_webui.md b/documentation/docs/integrations/spire_webui.md new file mode 100644 index 0000000000..bf9133ef5c --- /dev/null +++ b/documentation/docs/integrations/spire_webui.md @@ -0,0 +1,126 @@ +# Web UI SPIFFE Authentication via a Gateway/BFF + +Eviden KMS can establish a Web UI session from a **SPIFFE JWT-SVID** posted by a trusted +gateway (or backend-for-frontend, BFF) to `POST /ui/login_svid`. Browsers cannot reach the SPIRE +Workload API, so the gateway is the component that holds SPIFFE credentials; the browser never +sees the JWT-SVID. + +There is **no** login form or paste box for an SVID in the Web UI. A browser without a +session sees only an informational notice that the deployment uses SPIFFE sessions +established by a gateway. + +!!! warning "A session is only as specific as the identity behind it" + + The session identity is the `sub` (`spiffe:///`) of the JWT-SVID that + the gateway posts. If the gateway posts **its own** JWT-SVID whenever a browser has no + cookie, every anonymous browser receives a session as **one shared SPIFFE identity**: + there is no per-user authentication, authorization or audit. This is the same anti-pattern + as forcing every client onto a single `admin` account (rejected in + [ADR-2026-09-19](../adr/2026-09-19-spiffe-jwt-svid-authentication.md), ALT-003/ALT-004). + The gateway **must authenticate the end user first** (for example OIDC at the gateway) + and only then relay a session, ideally with an identity scoped to that user or group. + +--- + +## Server behaviour + +| Aspect | Behaviour | +|---|---| +| Enablement | `--jwt-svid-auth` (`[idp_auth] jwt_svid_auth = true`), global to all `--jwt-auth-provider` entries | +| Audience | Mandatory on every `--jwt-auth-provider` entry (server refuses to start otherwise); the SVID must carry a non-empty `aud` | +| Advertisement | `GET /ui/auth_method` lists `"SPIFFE"` in `auth_methods` (priority JWT > SPIFFE > AUTH_VERIFIER > CERT) | +| Session identity | Read by the UI via `GET /ui/whoami` | +| No session | The browser shows an informational notice, no form | + +### `POST /ui/login_svid` + +Request body: + +```json +{ "jwt_svid": "" } +``` + +| Status | Meaning | +|---|---| +| `200` | `{"next_step":"Authenticated"}`; the `auth_session` cookie is set | +| `401` | The SVID is invalid (bad signature, issuer, expiry, audience, or not a SPIFFE subject) | +| `500` | `--jwt-svid-auth` is not enabled, or the session could not be stored | + +Rules applied by the endpoint: + +- Only tokens whose `sub` starts with `spiffe://` are accepted. A token with an `email` claim + but a non-SPIFFE `sub` is rejected. +- If validation fails, the JWKS is refreshed once and validation is retried, to cope with + SPIRE key rotation. +- The full SPIFFE ID becomes the session `user_id`. The cookie (`auth_session`) is encrypted, + `HttpOnly` and `SameSite=Lax`. + +--- + +## Reference design (illustrative) + +!!! note + + The components below (Apache APISIX, `spiffe-helper`, `spiffe-mtls-reloader`) are an + **illustrative** deployment. They are not shipped or tested in this repository; only the + KMS side (`/ui/login_svid`, `/ui/whoami`, `/ui/auth_method`) is implemented and covered here. + Any gateway able to authenticate users and call the endpoint can be used. + +```mermaid +flowchart LR + Browser["Web Browser"] -->|"1. authenticate user (e.g. OIDC)"| GW["Gateway / BFF"] + Agent["SPIRE Agent
(Workload API)"] -->|"X.509-SVID + JWT-SVID"| GW + GW -->|"2. POST /ui/login_svid"| KMS["Eviden KMS"] + KMS -->|"JWKS"| OIDC["SPIRE OIDC
Discovery Provider"] + KMS -->|"Set-Cookie auth_session"| GW + GW -->|"3. proxy with cookie"| KMS +``` + +```mermaid +sequenceDiagram + autonumber + participant Browser + participant GW as Gateway / BFF + participant OIDC as SPIRE OIDC Discovery Provider + participant KMS as Eviden KMS + + Browser->>GW: GET / (no auth_session cookie) + GW->>Browser: Authenticate the end user (e.g. OIDC login) + Browser-->>GW: User authenticated + GW->>KMS: POST /ui/login_svid {"jwt_svid": "..."} + KMS->>OIDC: Fetch JWKS (cached; refreshed once on failure) + OIDC-->>KMS: Public keys + KMS->>KMS: Validate signature, issuer, expiry, audience, spiffe:// sub + KMS-->>GW: 200 {"next_step":"Authenticated"} + Set-Cookie auth_session + GW->>KMS: GET /ui/ (auth_session cookie) + KMS-->>GW: Web UI assets + GW-->>Browser: Web UI + Browser->>GW: GET /ui/whoami + GW->>KMS: Forward with cookie + KMS-->>Browser: 200 {"user_id": "spiffe://..."} +``` + +### Transport (optional mTLS) + +The gateway may connect to the KMS with an X.509-SVID +(`clients_ca_cert_file = "/etc/kms/certs/spire-bundle.crt"`). Client-certificate +authentication runs **before** the JWT middleware: if the presented certificate has a CN, +the request is authenticated as that CN and any JWT-SVID or session cookie is ignored. Use +CN-less client certificates, or separate listeners, if the SPIFFE session identity must win. + +### Server configuration + +```toml +[ui_config] +enable = true + +[idp_auth] +jwt_auth_provider = [ + "https://:,https://:/keys," +] +jwt_svid_auth = true + +[tls] +tls_cert_file = "/etc/kms/certs/tls.crt" +tls_key_file = "/etc/kms/certs/tls.key" +``` diff --git a/documentation/docs/kms_clients/cli/main_commands.md b/documentation/docs/kms_clients/cli/main_commands.md index e02957275a..f8b4ac38e9 100644 --- a/documentation/docs/kms_clients/cli/main_commands.md +++ b/documentation/docs/kms_clients/cli/main_commands.md @@ -3417,6 +3417,8 @@ Login to the KMS server identity provider. **`approle`** [[17.3]](#173-ckms-login-approle) Login using a Vault-compatible `AppRole` identity +**`spire`** [[17.4]](#174-ckms-login-spire) Fetch a SPIFFE JWT-SVID from the local SPIRE Agent's Workload API and use it as the KMS access token + --- ## 17.1 ckms login oauth @@ -3457,6 +3459,23 @@ Login using a Vault-compatible `AppRole` identity +--- + +## 17.4 ckms login spire + +Fetch a SPIFFE JWT-SVID from the local SPIRE Agent's Workload API and use it as the KMS access token + +### Usage +`ckms login spire [options]` +### Arguments +`--audience ` The JWT audience value, forwarded to the Workload API's JWT-SVID fetch call. Must match a `--jwt-auth-provider` audience configured on the KMS server + +`--spiffe-id ` The SPIFFE ID of the JWT-SVID to request, when the local agent serves more than one identity to this workload (optional — omit to accept whichever identity the agent returns) + +`--socket-path ` Local SPIRE Agent Workload API endpoint: an absolute socket path (e.g. `/tmp/spire-agent/public/api.sock`) or a `unix:///path` / `tcp://host:port` URI. When omitted, the `SPIFFE_ENDPOINT_SOCKET` environment variable is used + + + --- diff --git a/documentation/nav.yml b/documentation/nav.yml index 961202e54e..47457210c7 100644 --- a/documentation/nav.yml +++ b/documentation/nav.yml @@ -124,6 +124,8 @@ nav: - Zero Trust M2M (SPIRE / SPIFFE): - Vault-compatible integration: integrations/spire_spiffe.md - Native KMIP 2.1 plugins: integrations/spire_spiffe_kmip_plugin.md + - Web UI gateway SPIFFE authentication: integrations/spire_webui.md + - CLI (ckms) SPIFFE authentication: integrations/spire_ckms.md - Installation: - Getting started: installation/installation_getting_started.md - Deploying in a Cosmian Confidential VM: installation/marketplace_guide.md diff --git a/pkg/kms.toml b/pkg/kms.toml index ea4e57dbaa..fd29a9b446 100644 --- a/pkg/kms.toml +++ b/pkg/kms.toml @@ -245,6 +245,15 @@ hostname = "0.0.0.0" # "https://keycloak.example.com/auth/realms/myrealm,," # ] +# Accept SPIFFE JWT-SVIDs from the configured `--jwt-auth-provider` issuers. +# +# A SPIFFE JWT-SVID carries no `email` claim, only a `sub` claim shaped as `spiffe:///`. When this flag is enabled, a JWT that validates successfully (signature, issuer, audience, expiry) against a configured issuer but has no `email` claim is authenticated using its `sub` claim **only if** `sub` starts with `spiffe://`; every other JWT still requires `email` as before. +# +# The flag is global: it applies to every `--jwt-auth-provider`. Every provider MUST specify an audience (`issuer,jwks_uri,audience`), and the SVID MUST carry a matching `aud` claim; otherwise the server refuses to start, since an SVID minted for another service could be replayed against the KMS. +# +# Disabled by default: enabling it only makes sense when the configured issuer(s) are a SPIFFE-aware JWKS source (e.g. a SPIRE OIDC Discovery Provider). +# jwt_svid_auth = false + [workspace] # The root folder where the KMS will store its data A relative path is taken relative to the user's HOME directory # root_data_path = "./cosmian-kms" diff --git a/ui/src/App.tsx b/ui/src/App.tsx index fc85ee1bf3..474f6c2ce9 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -190,9 +190,11 @@ const AppContent: React.FC = ({ isDarkMode, setIsDarkMode, wasm // certificate prompt. Only if there is no session do we probe the cert. const sessionMethod: AuthMethod = methods.includes("JWT") ? "JWT" - : methods.includes("AUTH_VERIFIER") - ? "AUTH_VERIFIER" - : undefined; + : methods.includes("SPIFFE") + ? "SPIFFE" + : methods.includes("AUTH_VERIFIER") + ? "AUTH_VERIFIER" + : undefined; if (sessionMethod) { const data = await fetchWhoAmI(location); diff --git a/ui/src/components/layout/MainLayout.tsx b/ui/src/components/layout/MainLayout.tsx index fe76a033d4..e89d80c744 100644 --- a/ui/src/components/layout/MainLayout.tsx +++ b/ui/src/components/layout/MainLayout.tsx @@ -133,14 +133,14 @@ const MainLayout: React.FC = ({ isDarkMode, setIsDarkMode, auth )} - {authMethod === "JWT" || authMethod === "AUTH_VERIFIER" ? ( + {authMethod === "JWT" || authMethod === "AUTH_VERIFIER" || authMethod === "SPIFFE" ? (
{userId && ( {userId} )} -
@@ -152,8 +152,8 @@ const MainLayout: React.FC = ({ isDarkMode, setIsDarkMode, auth )} {onCertLogout && ( - )} diff --git a/ui/src/i18n/locales/en/layout.json b/ui/src/i18n/locales/en/layout.json index cf81ce15a8..06e3537fdd 100644 --- a/ui/src/i18n/locales/en/layout.json +++ b/ui/src/i18n/locales/en/layout.json @@ -36,6 +36,8 @@ "oidc": "OIDC", "certificate": "Client certificate", "authVerifier": "Username & password", + "spiffe": "SPIFFE JWT-SVID", + "spiffeGatewayNotice": "This server authenticates browser sessions with SPIFFE JWT-SVIDs. The session is established by the ingress gateway in front of the KMS; open the Web UI through that gateway.", "accessKms": "ACCESS KMS", "certErrorDescription": "No client certificate was provided or it is invalid. If the problem persists, close all instances of your browser and relaunch with the correct client certificate previously loaded." } diff --git a/ui/src/i18n/locales/fr/layout.json b/ui/src/i18n/locales/fr/layout.json index e3df5c025c..1ecf701ab5 100644 --- a/ui/src/i18n/locales/fr/layout.json +++ b/ui/src/i18n/locales/fr/layout.json @@ -36,6 +36,8 @@ "oidc": "OIDC", "certificate": "Certificat client", "authVerifier": "Nom d'utilisateur et mot de passe", + "spiffe": "SPIFFE JWT-SVID", + "spiffeGatewayNotice": "Ce serveur authentifie les sessions du navigateur avec des JWT-SVID SPIFFE. La session est établie par la passerelle d'entrée placée devant le KMS ; ouvrez l'interface web via cette passerelle.", "accessKms": "ACCÉDER AU KMS", "certErrorDescription": "Aucun certificat client n'a été fourni ou il est invalide. Si le problème persiste, fermez toutes les instances de votre navigateur et relancez-le avec le certificat client correct préalablement chargé." } diff --git a/ui/src/i18n/locales/zh-CN/layout.json b/ui/src/i18n/locales/zh-CN/layout.json index ab574ae3d8..e565aeb7b6 100644 --- a/ui/src/i18n/locales/zh-CN/layout.json +++ b/ui/src/i18n/locales/zh-CN/layout.json @@ -36,6 +36,8 @@ "oidc": "OIDC", "certificate": "客户端证书", "authVerifier": "用户名与密码", + "spiffe": "SPIFFE JWT-SVID", + "spiffeGatewayNotice": "此服务器使用 SPIFFE JWT-SVID 对浏览器会话进行身份验证。会话由 KMS 前端的入口网关建立,请通过该网关打开 Web 界面。", "accessKms": "访问 KMS", "certErrorDescription": "未提供客户端证书或证书无效。如果问题持续存在,请关闭浏览器的所有实例,并使用之前加载的正确客户端证书重新启动。" } diff --git a/ui/src/pages/LoginPage.tsx b/ui/src/pages/LoginPage.tsx index b87afb0eea..1e9d9df85c 100644 --- a/ui/src/pages/LoginPage.tsx +++ b/ui/src/pages/LoginPage.tsx @@ -200,6 +200,15 @@ const LoginPage: React.FC = ({ auth, error, authMethods, onCertAuthe + ) : authMethods?.includes("SPIFFE") ? ( + ) : null} {/* Secondary action(s): nothing for a single method, a button for diff --git a/ui/src/utils/utils.ts b/ui/src/utils/utils.ts index 50131b1dea..133bc2a312 100644 --- a/ui/src/utils/utils.ts +++ b/ui/src/utils/utils.ts @@ -1,4 +1,4 @@ -export type AuthMethod = "None" | "JWT" | "CERT" | "AUTH_VERIFIER" | undefined; +export type AuthMethod = "None" | "JWT" | "CERT" | "AUTH_VERIFIER" | "SPIFFE" | undefined; /** Root of the Cosmian docs site (not the KMS-specific book — see `docsUrl`). */ export const DOCS_BASE_URL = "https://docs.cosmian.com"; diff --git a/ui/tests/e2e/spiffe-jwt-svid-auth.spec.ts b/ui/tests/e2e/spiffe-jwt-svid-auth.spec.ts new file mode 100644 index 0000000000..b7b53edbfc --- /dev/null +++ b/ui/tests/e2e/spiffe-jwt-svid-auth.spec.ts @@ -0,0 +1,62 @@ +/** + * SPIFFE JWT-SVID — Web UI / HTTP API Authentication E2E tests. + * + * Validates that: + * 1. An HTTP request with Authorization: Bearer is authenticated by the KMS server. + * 2. The KMS resolves the authenticated user as the SPIFFE ID (e.g., spiffe://...). + * 3. Subsequent API calls (e.g. GET /me, GET /access/owned, KMIP operations) execute under this identity. + */ +import { expect, test } from "@playwright/test"; + +const KMS_URL = process.env.PLAYWRIGHT_KMS_URL ?? "https://127.0.0.1:9998"; +const JWT_SVID_TOKEN = process.env.TEST_JWT_SVID_TOKEN; +const EXPECTED_SPIFFE_ID = process.env.TEST_SPIFFE_ID ?? "spiffe://cosmian-test-a.local/test-workload-app"; + +test.describe("SPIFFE JWT-SVID Authentication", () => { + test.skip(!JWT_SVID_TOKEN, "TEST_JWT_SVID_TOKEN environment variable not set"); + + test("GET /me with Bearer JWT-SVID returns SPIFFE ID user", async ({ request }) => { + const response = await request.get(`${KMS_URL}/me`, { + headers: { + Authorization: `Bearer ${JWT_SVID_TOKEN}`, + }, + ignoreHTTPSErrors: true, + }); + + expect(response.status()).toBe(200); + const data = await response.json(); + expect(data).toHaveProperty("user", EXPECTED_SPIFFE_ID); + }); + + test("GET /access/owned with Bearer JWT-SVID returns 200 list", async ({ request }) => { + const response = await request.get(`${KMS_URL}/access/owned`, { + headers: { + Authorization: `Bearer ${JWT_SVID_TOKEN}`, + }, + ignoreHTTPSErrors: true, + }); + + expect(response.status()).toBe(200); + const data = await response.json(); + expect(Array.isArray(data)).toBeTruthy(); + }); + + test("KMIP JSON Query operation authenticated with Bearer JWT-SVID", async ({ request }) => { + const response = await request.post(`${KMS_URL}/kmip/2_1`, { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${JWT_SVID_TOKEN}`, + }, + data: { + tag: "Query", + type: "Structure", + value: [{ tag: "QueryFunction", type: "Enumeration", value: "QueryServerInformation" }], + }, + ignoreHTTPSErrors: true, + }); + + expect(response.status()).toBe(200); + const data = await response.json(); + expect(data).toHaveProperty("tag", "QueryResponse"); + }); +}); diff --git a/ui/tests/e2e/spiffe-ui-login.spec.ts b/ui/tests/e2e/spiffe-ui-login.spec.ts new file mode 100644 index 0000000000..50f8e491ae --- /dev/null +++ b/ui/tests/e2e/spiffe-ui-login.spec.ts @@ -0,0 +1,61 @@ +/** + * SPIFFE JWT-SVID — Web UI session E2E test. + * + * The Web UI has no SPIFFE login form: the session is established by a gateway that + * posts a JWT-SVID to `POST /ui/login_svid`. This test plays the gateway (the request + * shares its cookie jar with the browser context), then loads the SPA and checks that + * it resolves the session identity to the SPIFFE ID carried by the token's `sub` claim. + * + * Requires a real UI build served by the KMS (`ui/dist`); a placeholder index.html + * cannot render the SPA. + */ +import { expect, test } from "@playwright/test"; + +const KMS_URL = process.env.PLAYWRIGHT_KMS_URL ?? "https://127.0.0.1:9998"; +const JWT_SVID_TOKEN = process.env.TEST_JWT_SVID_TOKEN; +const EXPECTED_SPIFFE_ID = process.env.TEST_SPIFFE_ID ?? "spiffe://cosmian-test-a.local/webui-demo-user"; + +test.describe("SPIFFE JWT-SVID Web UI session", () => { + test.skip(!JWT_SVID_TOKEN, "TEST_JWT_SVID_TOKEN environment variable not set"); + + test("advertises the SPIFFE auth method", async ({ request }) => { + const response = await request.get(`${KMS_URL}/ui/auth_method`, { ignoreHTTPSErrors: true }); + expect(response.status()).toBe(200); + const data = await response.json(); + expect(data.auth_methods).toContain("SPIFFE"); + }); + + test("rejects a malformed JWT-SVID on /ui/login_svid", async ({ page }) => { + const response = await page.request.post(`${KMS_URL}/ui/login_svid`, { + // Not a JWT at all: rejected whatever the validation mode (signature/audience checks + // are covered by the Rust unit tests and the Linux run of the mise suite). + data: { jwt_svid: "not-a-jwt" }, + ignoreHTTPSErrors: true, + }); + expect(response.status()).toBe(401); + }); + + // Only meaningful when the KMS really verifies signatures (strict mode, exported by the + // mise task on Linux/CI); an `insecure` build decodes tokens without checking them. + test("rejects a JWT-SVID with a tampered signature", async ({ page }) => { + test.skip(!process.env.TEST_JWT_STRICT, "TEST_JWT_STRICT not set (KMS built without signature validation)"); + const [header, payload, signature] = JWT_SVID_TOKEN!.split("."); + const tampered = `${header}.${payload}.${signature.startsWith("A") ? "B" : "A"}${signature.slice(1)}`; + const response = await page.request.post(`${KMS_URL}/ui/login_svid`, { + data: { jwt_svid: tampered }, + ignoreHTTPSErrors: true, + }); + expect(response.status()).toBe(401); + }); + + test("gateway-established session is picked up by the UI", async ({ page }) => { + const login = await page.request.post(`${KMS_URL}/ui/login_svid`, { + data: { jwt_svid: JWT_SVID_TOKEN }, + ignoreHTTPSErrors: true, + }); + expect(login.status()).toBe(200); + + await page.goto(`${KMS_URL}/ui/locate`, { waitUntil: "domcontentloaded" }); + await expect(page.getByTestId("session-user-tag")).toContainText(EXPECTED_SPIFFE_ID); + }); +});