From 5dd78a43a028759896bff3b6e16671870e9f0572 Mon Sep 17 00:00:00 2001 From: Aadhar Agarwal Date: Tue, 8 Sep 2026 12:23:34 -0700 Subject: [PATCH] feat(ipe): add signed policy loading and Azure mode selection Add audit-mode IPE policy staging, signed UKI credential loading, and IMDS-driven Azure activation while keeping enforcing mode unsupported. Include the required RPM mapping and offline/Azure validation coverage. Signed-off-by: Aadhar Agarwal Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c80d168d-6860-40e3-8174-bf217b1a0e88 --- .pipelines/github-pr-validation.yml | 23 +- acl/build_rpm_image.sh | 178 ++++++++ acl/docs/BUILD_RPM_IMAGE_README.md | 35 ++ acl/docs/architecture.md | 3 + .../azure-security-profile-test-common.sh | 102 +++++ acl/tests/ipe/offline/run.sh | 19 + .../ipe/offline/test-ipe-loader-runtime.sh | 394 ++++++++++++++++++ .../ipe/offline/test-ipe-mode-toggle-logic.sh | 86 ++++ .../ipe/offline/test-ipe-policy-input.sh | 207 +++++++++ .../test-run-sdk-container-ipe-policy.sh | 120 ++++++ acl/tests/ipe/run-ipe-mode-toggle-test.sh | 240 +++++++++++ acl/tests/ipe/run-ipe-permissive-test.sh | 129 ++++++ acl/tests/run-selinux-toggle-test.sh | 129 +----- ...test-azure-security-profile-test-common.sh | 34 ++ acl/tests/test-rpm-provider-selection.sh | 50 +++ acl/validate/validate_common.sh | 7 +- build_library/build_image_util.sh | 1 + .../acl-node-security-profile.sh | 75 ++++ .../dracut-acl-ipe-load/acl-ipe-load.service | 19 + .../dracut-acl-ipe-load/acl-ipe-load.sh | 114 +++++ .../dracut-acl-ipe-load/module-setup.sh | 23 + .../acl-selinux-toggle.sh | 39 +- .../dracut-acl-selinux-toggle/module-setup.sh | 5 + .../ipe/acl-ipe-boot-policy.pol | 7 + build_library/rpm/dracut_install.sh | 27 ++ build_library/rpm/ensure_ephemeral_cert.sh | 31 ++ build_library/rpm/ipe_verity.sh | 11 + build_library/rpm/package_catalog.yaml | 11 +- build_library/rpm/rpm_install.sh | 130 +++++- build_library/rpm/sign_uki_ephemeral.sh | 57 ++- build_library/rpm/uki_install.sh | 122 +++++- ci-automation/vendor-testing/azure.sh | 29 ++ image_to_vm.sh | 9 +- run_azure_tests.sh | 2 + run_sdk_container | 28 +- sdk_lib/sdk_container_common.sh | 2 +- sdk_lib/sdk_entry.sh | 34 ++ 37 files changed, 2343 insertions(+), 189 deletions(-) create mode 100755 acl/tests/azure-security-profile-test-common.sh create mode 100755 acl/tests/ipe/offline/run.sh create mode 100755 acl/tests/ipe/offline/test-ipe-loader-runtime.sh create mode 100755 acl/tests/ipe/offline/test-ipe-mode-toggle-logic.sh create mode 100755 acl/tests/ipe/offline/test-ipe-policy-input.sh create mode 100755 acl/tests/ipe/offline/test-run-sdk-container-ipe-policy.sh create mode 100755 acl/tests/ipe/run-ipe-mode-toggle-test.sh create mode 100755 acl/tests/ipe/run-ipe-permissive-test.sh create mode 100755 acl/tests/test-azure-security-profile-test-common.sh create mode 100755 acl/tests/test-rpm-provider-selection.sh create mode 100644 build_library/rpm/additional_files/acl-node-security-profile.sh create mode 100644 build_library/rpm/additional_files/dracut-acl-ipe-load/acl-ipe-load.service create mode 100644 build_library/rpm/additional_files/dracut-acl-ipe-load/acl-ipe-load.sh create mode 100644 build_library/rpm/additional_files/dracut-acl-ipe-load/module-setup.sh create mode 100644 build_library/rpm/additional_files/ipe/acl-ipe-boot-policy.pol create mode 100755 build_library/rpm/ensure_ephemeral_cert.sh create mode 100644 build_library/rpm/ipe_verity.sh diff --git a/.pipelines/github-pr-validation.yml b/.pipelines/github-pr-validation.yml index c3cd003e98c..ccd4836605c 100644 --- a/.pipelines/github-pr-validation.yml +++ b/.pipelines/github-pr-validation.yml @@ -20,12 +20,29 @@ pr: - aclmain - release/* +parameters: + - name: aclPipelinesRef + type: string + default: refs/heads/main + - name: buildMantle + type: boolean + default: false + - name: runKolaAzure + type: boolean + default: false + - name: kolaTests + type: string + default: default + - name: maxRuns + type: number + default: 5 + resources: repositories: - repository: acl_pipelines type: git name: ACL/acl-pipelines - ref: refs/heads/main + ref: ${{ parameters.aclPipelinesRef }} - repository: azure_container_linux type: github endpoint: github.com_acl @@ -47,3 +64,7 @@ extends: aclRef: $(Build.SourceBranch) mantleRef: 'aclmain' testGeneratePackageManifest: true + buildMantle: ${{ parameters.buildMantle }} + runKolaAzure: ${{ parameters.runKolaAzure }} + kolaTests: ${{ parameters.kolaTests }} + maxRuns: ${{ parameters.maxRuns }} diff --git a/acl/build_rpm_image.sh b/acl/build_rpm_image.sh index 1441be08697..1ac18949010 100755 --- a/acl/build_rpm_image.sh +++ b/acl/build_rpm_image.sh @@ -35,6 +35,9 @@ # --download-rpms [no-op] Kept for pipeline compatibility # --group=GROUP Image group: developer|production|prod (default: production) # --help Show this help message +# --ipe-mode=MODE IPE runtime mode: disabled|audit|enforcing (default: disabled) +# 'enforcing' is reserved and rejected at build time. +# --ipe-signing-mode=MODE IPE signing mode: ephemeral|esrp (default: ephemeral) # --img-name=NAME Base image name prefix (default: acl_production) # Final image will be NAME_image.bin, VM image will be NAME_qemu_uefi_image.img # --keep-vm Keep VM running after scripts complete (write state to .vm-state.env) @@ -171,6 +174,19 @@ export IMAGE_VERSION_ID="${IMAGE_VERSION_ID:-}" export IMAGE_BUILD_ID="${IMAGE_BUILD_ID:-}" # Extra kernel cmdline args baked into a UKI debug addon (e.g., for boot profiling) export EXTRA_KERNEL_CMDLINE="${EXTRA_KERNEL_CMDLINE:-}" +# Build-time IPE mode and signing mode. Runtime activation is selected only +# through the Azure IMDS acl-node-security-profile tag. +IPE_MODE_OVERRIDE_SET=false +if [[ -v ACL_IPE_MODE ]]; then + IPE_MODE_OVERRIDE_SET=true +fi +ACL_IPE_MODE="${ACL_IPE_MODE:-disabled}" + +IPE_SIGNING_MODE_OVERRIDE_SET=false +if [[ -v ACL_IPE_SIGNING_MODE ]]; then + IPE_SIGNING_MODE_OVERRIDE_SET=true +fi +ACL_IPE_SIGNING_MODE="${ACL_IPE_SIGNING_MODE:-ephemeral}" # Pipeline build identifier — used for deterministic gallery image versions in CI. BUILD_ID="${BUILD_ID:-}" @@ -231,6 +247,117 @@ show_help() { exit 0 } +validate_ipe_boot_path() { + local vm_type="${1:-}" + + [[ "${ACL_IPE_CAPABLE}" == "false" ]] && return 0 + if [[ "${BOOTLOADER_MODE}" != "uki" ]]; then + error "IPE requires BOOTLOADER_MODE=uki" + return 1 + fi + if [[ "${SECURE_BOOT_ENABLED:-true}" != "true" ]]; then + error "IPE requires Secure Boot" + return 1 + fi + if [[ "${vm_type}" == "qemu" ]]; then + error "IPE is supported only by the Azure Secure Boot UKI path" + return 1 + fi +} + +validate_ipe_mode_value() { + case "${ACL_IPE_MODE}" in + disabled|audit) ;; + enforcing) + error "IPE mode 'enforcing' is reserved for future use and is not supported by this build (use 'audit' or 'disabled')" + return 1 + ;; + *) + error "Invalid IPE mode: ${ACL_IPE_MODE} (expected disabled, audit, or enforcing)" + return 1 + ;; + esac +} + +configure_ipe_mode() { + validate_ipe_mode_value || return 1 + case "${ACL_IPE_MODE}" in + disabled) ACL_IPE_CAPABLE=false ;; + audit) ACL_IPE_CAPABLE=true ;; + esac + case "${ACL_IPE_SIGNING_MODE}" in + ephemeral|esrp) ;; + *) + error "Invalid IPE signing mode: ${ACL_IPE_SIGNING_MODE} (expected ephemeral or esrp)" + return 1 + ;; + esac + export ACL_IPE_MODE ACL_IPE_SIGNING_MODE ACL_IPE_CAPABLE +} + +load_artifact_ipe_signing_mode() { + local artifact_dir="$1" + local marker_file="${artifact_dir}/ipe-signing-mode" + local artifact_signing_mode + + if [[ ! -r "${marker_file}" ]]; then + if [[ -d "${artifact_dir}/acl-ipe-policy" ]]; then + error "IPE policy assets found without ${marker_file}" + return 1 + fi + if [[ "${IPE_MODE_OVERRIDE_SET}" == "true" && + "${ACL_IPE_MODE}" != "disabled" ]]; then + error "Source image has no IPE assets; rebuild it with --ipe-mode=audit" + return 1 + fi + ACL_IPE_MODE=disabled + configure_ipe_mode + return 0 + fi + + read -r artifact_signing_mode < "${marker_file}" + case "${artifact_signing_mode}" in + ephemeral|esrp) ;; + *) + error "Invalid IPE signing mode in ${marker_file}: ${artifact_signing_mode}" + return 1 + ;; + esac + + if [[ "${IPE_MODE_OVERRIDE_SET}" != "true" ]]; then + ACL_IPE_MODE=audit + elif [[ "${ACL_IPE_MODE}" == "disabled" ]]; then + error "Requested IPE mode 'disabled' does not match source image, which has IPE assets (signing mode '${artifact_signing_mode}')" + return 1 + fi + + if [[ "${IPE_SIGNING_MODE_OVERRIDE_SET}" == "true" && + "${ACL_IPE_SIGNING_MODE}" != "${artifact_signing_mode}" ]]; then + error "Requested IPE signing mode '${ACL_IPE_SIGNING_MODE}' does not match source image signing mode '${artifact_signing_mode}'" + return 1 + fi + ACL_IPE_SIGNING_MODE="${artifact_signing_mode}" + + configure_ipe_mode +} + +operation_uses_vm_image() { + [[ "$BUILD_VM_IMAGE" == "true" ]] || + [[ "$BUILD_TEST_IMAGE" == "true" ]] || + [[ "$START_VM" == "true" ]] || + [[ "$RUN_KOLA_TESTS" == "true" ]] +} + +operation_uses_gallery_image() { + [[ -n "$ACG_IMAGE_VERSION_ID" ]] || [[ "$REUSE_IMAGE" == "true" ]] +} + +operation_uses_local_image_artifact() { + [[ "$BUILD_IMAGE" != "true" ]] && + ! operation_uses_gallery_image && + operation_uses_vm_image +} + # Parse command line arguments parse_args() { while [[ $# -gt 0 ]]; do @@ -251,6 +378,26 @@ parse_args() { GROUP="$2" shift 2 ;; + --ipe-mode=*) + ACL_IPE_MODE="${1#*=}" + IPE_MODE_OVERRIDE_SET=true + shift + ;; + --ipe-mode) + ACL_IPE_MODE="$2" + IPE_MODE_OVERRIDE_SET=true + shift 2 + ;; + --ipe-signing-mode=*) + ACL_IPE_SIGNING_MODE="${1#*=}" + IPE_SIGNING_MODE_OVERRIDE_SET=true + shift + ;; + --ipe-signing-mode) + ACL_IPE_SIGNING_MODE="$2" + IPE_SIGNING_MODE_OVERRIDE_SET=true + shift 2 + ;; --img-name=*) IMG_NAME="${1#*=}" shift @@ -601,6 +748,10 @@ parse_args() { esac done + # Fail unsupported IPE modes as early as possible, before any + # staging/build work (including artifact/gallery-image inspection below). + validate_ipe_mode_value || exit 1 + # Validate --retry value if [[ "$RETRY_ATTEMPTS" != "0" ]]; then if ! [[ "$RETRY_ATTEMPTS" =~ ^[1-9][0-9]*$ ]]; then @@ -613,6 +764,19 @@ parse_args() { fi fi + if operation_uses_local_image_artifact; then + load_artifact_ipe_signing_mode \ + "${SCRIPT_DIR}/__build__/images/images/${BOARD}/latest" || + exit 1 + fi + if operation_uses_gallery_image && + [[ "${START_VM}" == "true" || "${RUN_KOLA_TESTS}" == "true" ]] && + [[ "${IPE_MODE_OVERRIDE_SET}" == "false" ]]; then + warn "ACL_IPE_MODE is not set for gallery image validation; defaulting to disabled" + fi + + configure_ipe_mode || exit 1 + # Normalize group name case "$GROUP" in prod|production) @@ -636,10 +800,19 @@ parse_args() { ;; esac fi + local ipe_vm_type="" + if operation_uses_vm_image; then + ipe_vm_type="${VM_TYPE}" + fi + validate_ipe_boot_path "${ipe_vm_type}" || exit 1 # Add platform-specific host-side tests when --run-tests is used. if [[ "${RUN_TESTS:-false}" == "true" ]] && [[ "$VM_TYPE" == "azure" ]]; then RUN_HOST_SCRIPTS+=("./acl/tests/run-selinux-toggle-test.sh") + if [[ "${ACL_IPE_CAPABLE}" == "true" ]] && + [[ "${SECURE_BOOT_ENABLED:-true}" == "true" ]]; then + RUN_HOST_SCRIPTS+=("./acl/tests/ipe/run-ipe-mode-toggle-test.sh") + fi fi if [[ "$REUSE_IMAGE" == "true" ]]; then @@ -870,6 +1043,7 @@ build_image() { info " Output: ${OUTPUT_ROOT}" info " Staging Dir: ${STAGING_DIR}" info " Force Rebuild: ${FORCE_REBUILD}" + info " IPE Mode: ${ACL_IPE_MODE} (signing: ${ACL_IPE_SIGNING_MODE})" echo # Count RPMs @@ -1054,6 +1228,9 @@ build_vm_image() { # pulling a stale version.txt into local dev builds. local version_args=() local from_dir="${SCRIPT_DIR}/__build__/images/images/${BOARD}/latest" + load_artifact_ipe_signing_mode "${from_dir}" || exit 1 + validate_ipe_boot_path "${vm_type}" || exit 1 + if [[ "${NO_TTY:-false}" == "true" ]] && [[ -f "${from_dir}/version.txt" ]]; then info "Installing artifact version.txt into manifest location (CI mode)" cp "${from_dir}/version.txt" \ @@ -1261,6 +1438,7 @@ main() { section "Azure Container Linux Image Builder" info "Building ${BOARD} ${GROUP} image using Azure Linux RPMs" + info "IPE mode: ${ACL_IPE_MODE} (signing: ${ACL_IPE_SIGNING_MODE})" check_prerequisites print_summary diff --git a/acl/docs/BUILD_RPM_IMAGE_README.md b/acl/docs/BUILD_RPM_IMAGE_README.md index 496d633993e..fc460750f41 100644 --- a/acl/docs/BUILD_RPM_IMAGE_README.md +++ b/acl/docs/BUILD_RPM_IMAGE_README.md @@ -155,8 +155,43 @@ Build the Flatcar production image using RPM package sources. ./acl/build_rpm_image.sh --rebuild ``` +Development and test images can include IPE assets signed with a build-local +ephemeral PKCS#7 signature: + +```bash +./acl/build_rpm_image.sh --rebuild --ipe-mode=audit --ipe-signing-mode=ephemeral +``` + +Production IPE validation builds use `--ipe-signing-mode=esrp`; normal +production pipeline defaults remain `--ipe-mode=disabled` until activation is +approved. The build still creates a build-local ephemeral candidate CMS so +pre-publish VHDs remain bootable; the Pipelines collector exports the staged +raw policy from `${BUILD_DIR}/acl-ipe-policy/` for downstream ESRP signing by +definition 5425. + +```bash +./acl/build_rpm_image.sh --rebuild --ipe-mode=audit --ipe-signing-mode=esrp +``` + +The candidate CMS is staged at +`${BUILD_DIR}/acl-ipe-policy/acl-ipe-policy.p7b.cred` and installed as a +per-UKI `.extra.d` credential companion. The UKI cmdline includes an +`acl.ipe.policy_sha256=` token that binds the credential to the signed +kernel command line. At boot, the initramfs loader validates the credential +SHA-256, loads the policy into the kernel IPE subsystem, and only activates it +when Azure IMDS requests audit (permissive) mode. Loading is best effort on +Azure; validation or loading failures are logged and leave IPE inactive +without blocking boot. `enforcing` is reserved for future use: it is rejected +at build time, and a manually set runtime `ipe=enforcing` request is logged +as unsupported and left safely inactive — it never fails boot. + +IPE-capable VM images currently support only the Azure Secure Boot UKI path. +QEMU image conversion must use `--ipe-mode=disabled`, which is also the +default. + **Build output location:** `__build__/images/images/amd64-usr/latest/` + ### Phase 4: Build VM Image (Optional) Convert the production image to a VM-ready format. The script supports building two different types of images: diff --git a/acl/docs/architecture.md b/acl/docs/architecture.md index d397c9597c9..83e8acd4a0c 100644 --- a/acl/docs/architecture.md +++ b/acl/docs/architecture.md @@ -55,6 +55,9 @@ The `/usr` partition (USR-A) is a read-only btrfs filesystem with zstd compressi - The verity hash tree is appended to the USR partition data. - At boot, `systemd-veritysetup` activates the verity device using kernel command-line parameters embedded in the UKI: `systemd.verity_usr_data`, `systemd.verity_usr_hash`, and `systemd.verity_usr_options=hash-offset=,panic-on-corruption`. +- The current image has one UKI for USR-A. Its detached root-hash signature is stored beside the UKI as a hash-addressed `*.cred` companion. systemd-stub transports it into the initramfs at `/.extra/credentials/`, and the signed UKI command line references the matching filename through `root-hash-signature=`. +- IPE assets are selected through two independent build-time controls: `ACL_IPE_MODE=disabled|audit|enforcing` (default `disabled`; `enforcing` is reserved and rejected at build time) and `ACL_IPE_SIGNING_MODE=ephemeral|esrp` (default `ephemeral`), which only applies when `ACL_IPE_MODE=audit`. Pipelines enable IPE only for Azure Secure Boot UKI images. +- On IPE-capable (`audit`) images, the signed policy candidate CMS is staged at `${BUILD_DIR}/acl-ipe-policy/acl-ipe-policy.p7b.cred` and installed as a per-UKI `.extra.d` credential companion at `EFI/Linux/.efi.extra.d/acl-ipe-policy.p7b.cred`. The UKI cmdline includes an `acl.ipe.policy_sha256=` token that binds the credential to the signed kernel command line. At boot, systemd-stub makes the credential available at `/.extra/credentials/acl-ipe-policy.p7b.cred`. On Azure, the initramfs loader validates the credential SHA-256, loads the policy into the kernel, and activates it only when IMDS requests `ipe=audit` or the legacy alias `ipe=permissive` (runtime enforcement state `0`); `ipe=disabled`/`ipe=off` leave it loaded but inactive, and a manually requested `ipe=enforcing` is logged as an explicit unsupported diagnostic and safely falls back to inactive without failing boot. Loading is best effort: validation or loading failures are logged and leave IPE inactive without blocking boot. Runtime mode is never persisted as image/artifact metadata. Development and test builds use `ephemeral` signing mode. Production IPE validation uses `esrp` signing mode (the build still creates a build-local ephemeral candidate so pre-publish VHDs remain bootable; definition 5425 replaces it later), while normal production pipeline defaults remain `disabled` until activation is approved. The sole producer marker is the optional `${BUILD_DIR}/ipe-signing-mode` file (`ephemeral` or `esrp`); its absence means disabled, and `disabled` builds remove any staged policy data and stale ephemeral private-key/cert material. - Any corruption of `/usr` causes an immediate kernel panic, preventing the system from running a tampered image. The A/B partition scheme (USR-A / USR-B) enables safe updates: the inactive slot is written, verified, and then atomically switched on reboot. diff --git a/acl/tests/azure-security-profile-test-common.sh b/acl/tests/azure-security-profile-test-common.sh new file mode 100755 index 00000000000..df1db2377ba --- /dev/null +++ b/acl/tests/azure-security-profile-test-common.sh @@ -0,0 +1,102 @@ +#!/bin/bash + +SSH_OPTS=() + +setup_ssh_opts() { + SSH_OPTS=( + -o StrictHostKeyChecking=no + -o UserKnownHostsFile=/dev/null + -o BatchMode=yes + -o ConnectTimeout=10 + -o ServerAliveInterval=5 + -o ServerAliveCountMax=2 + -i "$VM_SSH_KEY" + ) +} + +ssh_cmd() { + ssh "${SSH_OPTS[@]}" "${VM_SSH_USER}@${VM_IP}" "$@" +} + +imds_security_profile() { + local raw + raw=$(ssh_cmd "curl -sf -H Metadata:true --noproxy '*' \ + 'http://169.254.169.254/metadata/instance/compute/tagsList?api-version=2021-02-01'" \ + 2>/dev/null) || return 1 + jq -r '.[] | select(.name=="acl-node-security-profile") | .value' <<<"$raw" +} + +boot_id() { + ssh_cmd 'cat /proc/sys/kernel/random/boot_id' 2>/dev/null +} + +set_security_profile_tag() { + local value="$1" + local vm_id + + # Use the generic ARM tag endpoint to avoid round-tripping unrelated VM + # properties through the Compute RP. + vm_id=$(az vm show --resource-group "$VM_RG" --name "$VM_NAME" --query id -o tsv) + if [[ -z "$value" ]]; then + info "Removing acl-node-security-profile tag..." + az tag update \ + --resource-id "$vm_id" \ + --operation delete \ + --tags "acl-node-security-profile=" \ + --output none + else + info "Setting acl-node-security-profile=${value}..." + az tag update \ + --resource-id "$vm_id" \ + --operation merge \ + --tags "acl-node-security-profile=${value}" \ + --output none + fi + + info "Waiting for in-guest IMDS to report tag='${value:-}'..." + local deadline=$(( $(date +%s) + 60 )) seen + while (( $(date +%s) < deadline )); do + seen=$(imds_security_profile) && [[ "$seen" == "$value" ]] && return 0 + sleep 2 + done + error "IMDS did not converge to '${value:-}' within 60s" + return 1 +} + +reboot_and_wait() { + local old new reboot_timeout + reboot_timeout="${VM_BOOT_TIMEOUT:-$VM_SSH_TIMEOUT}" + old=$(boot_id) || { error "Cannot read boot_id - VM unreachable?"; return 1; } + info "Rebooting VM ${VM_NAME} via SSH (old boot_id=${old})..." + timeout --signal=TERM --kill-after=5s 15s \ + ssh "${SSH_OPTS[@]}" "${VM_SSH_USER}@${VM_IP}" "sudo reboot" || true + local deadline=$(( $(date +%s) + reboot_timeout )) + while (( $(date +%s) < deadline )); do + new=$(boot_id) && [[ "$new" != "$old" ]] && { + info "VM rebooted (new boot_id=${new})" + return 0 + } + sleep 2 + done + warn "VM did not come back after reboot within ${reboot_timeout}s - capturing VM diagnostics" + + local diag_dir="${DIAGNOSTICS_DIR:-/tmp}" + mkdir -p "$diag_dir" + local prefix="${diag_dir}/$(date +%Y%m%d-%H%M%S)-${VM_NAME}" + az vm get-instance-view --resource-group "$VM_RG" --name "$VM_NAME" \ + --query 'instanceView.{statuses:statuses,vmAgent:vmAgent.statuses}' \ + -o json 2>&1 | tee "${prefix}-instance-view.json" || true + az vm boot-diagnostics get-boot-log --resource-group "$VM_RG" --name "$VM_NAME" 2>&1 \ + | jq -r . > "${prefix}-serial.log" || true + + info "Full serial log: ${prefix}-serial.log ($(wc -c <"${prefix}-serial.log") bytes); last 200 lines:" + tail -200 "${prefix}-serial.log" | sed 's/^/ [serial] /' || true + info "Diagnostics saved to ${prefix}-{instance-view.json,serial.log}" + error "VM did not come back after reboot" + return 1 +} + +set_security_profile_and_reboot() { + set_security_profile_tag "$1" + reboot_and_wait +} diff --git a/acl/tests/ipe/offline/run.sh b/acl/tests/ipe/offline/run.sh new file mode 100755 index 00000000000..071e8ab9164 --- /dev/null +++ b/acl/tests/ipe/offline/run.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +set -euo pipefail + +TEST_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +shopt -s nullglob +tests=("${TEST_DIR}"/test-*.sh) + +if [[ ${#tests[@]} -eq 0 ]]; then + echo "No offline IPE tests found in ${TEST_DIR}" >&2 + exit 1 +fi + +for test in "${tests[@]}"; do + echo "=== Running ${test##*/} ===" + "${test}" +done + +echo "Offline IPE test suite passed" diff --git a/acl/tests/ipe/offline/test-ipe-loader-runtime.sh b/acl/tests/ipe/offline/test-ipe-loader-runtime.sh new file mode 100755 index 00000000000..cd1542fabd7 --- /dev/null +++ b/acl/tests/ipe/offline/test-ipe-loader-runtime.sh @@ -0,0 +1,394 @@ +#!/bin/bash +# Verify best-effort initramfs IPE policy loading and validation. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)" +LOADER="${SCRIPT_DIR}/build_library/rpm/additional_files/dracut-acl-ipe-load/acl-ipe-load.sh" +PROFILE_HELPER="${SCRIPT_DIR}/build_library/rpm/additional_files/acl-node-security-profile.sh" +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "${TMP_DIR}"' EXIT + +ZERO_HASH="0000000000000000000000000000000000000000000000000000000000000000" + +make_credential() { + local path="$1" + printf 'test-credential-content\n' > "${path}" +} + +prepare_case() { + local name="$1" + CASE_DIR="${TMP_DIR}/${name}" + IPE_DIR="${CASE_DIR}/ipe" + + mkdir -p "${IPE_DIR}" + : > "${IPE_DIR}/new_policy" + printf '9\n' > "${IPE_DIR}/enforce" + + cat > "${CASE_DIR}/security-profile.sh" <<'EOF' +acl_security_profile() { + printf '%s\n' 'ipe=permissive' +} +acl_security_profile_value() { + printf '%s\n' 'permissive' +} +EOF +} + +run_loader() { + local credential="${1:-${CASE_DIR}/credential.p7b.cred}" + ACL_IPE_DIR="${IPE_DIR}" \ + ACL_IPE_CMDLINE_FILE="${CASE_DIR}/cmdline" \ + ACL_IPE_CREDENTIAL_PATH="${credential}" \ + ACL_IPE_SECURITY_PROFILE_HELPER="${CASE_DIR}/security-profile.sh" \ + bash "${LOADER}" 2>&1 +} + +assert_best_effort_skip() { + local expected="$1" + local output + shift + + output="$(run_loader "$@")" + grep -Fq "WARNING: ${expected}" <<< "${output}" || + { echo "missing best-effort warning: ${expected}" >&2; return 1; } + grep -Fq 'continuing boot without activating IPE' <<< "${output}" || + { echo "loader did not report that boot would continue" >&2; return 1; } +} + +# ---- Test: absent token → succeed as disabled ---- +test_absent_token() { + prepare_case absent-token + printf 'root=/dev/sda1\n' > "${CASE_DIR}/cmdline" + run_loader +} + +# ---- Test: unreadable/missing cmdline → skip IPE and continue boot ---- +test_missing_cmdline() { + prepare_case missing-cmdline + rm -f "${CASE_DIR}/cmdline" + assert_best_effort_skip "could not read kernel command line" +} + +# ---- Test: valid load + inactive (IMDS off) ---- +test_valid_inactive_load() { + run_inactive_case valid-inactive off +} + +# ---- Test: 'disabled' is accepted as an alias for 'off' ---- +test_valid_disabled_alias_inactive() { + run_inactive_case valid-disabled-alias disabled +} + +run_inactive_case() { + local name="$1" imds_mode="$2" + prepare_case "${name}" + make_credential "${CASE_DIR}/credential.p7b.cred" + local h + h="$(sha256sum "${CASE_DIR}/credential.p7b.cred" | cut -d' ' -f1)" + printf 'flatcar.oem.id=azure acl.ipe.policy_sha256=%s\n' "${h}" > "${CASE_DIR}/cmdline" + + # Override IMDS to return the requested inactive-style mode + cat > "${CASE_DIR}/security-profile.sh" </. +# We simulate this by using a named pipe so the policies directory appears +# only after the loader writes to new_policy (passing the preexisting check). +test_valid_permissive() { + run_active_case valid-permissive permissive +} + +# ---- Test: 'audit' is accepted as an alias for 'permissive' ---- +test_valid_audit_alias_active() { + run_active_case valid-audit-alias audit +} + +run_active_case() { + local name="$1" imds_mode="$2" + prepare_case "${name}" + make_credential "${CASE_DIR}/credential.p7b.cred" + # Keep the writer blocked while the simulated kernel creates the policy + # directory after accepting the first byte. + dd if=/dev/zero bs=65536 count=64 >> "${CASE_DIR}/credential.p7b.cred" 2>/dev/null + local h + h="$(sha256sum "${CASE_DIR}/credential.p7b.cred" | cut -d' ' -f1)" + printf 'flatcar.oem.id=azure acl.ipe.policy_sha256=%s\n' "${h}" > "${CASE_DIR}/cmdline" + + cat > "${CASE_DIR}/security-profile.sh" </dev/null || { + # mkfifo unavailable (e.g. Windows) — skip this test + echo "skipping run_active_case (mkfifo not available)" + return 0 + } + ( + exec 3< "${IPE_DIR}/new_policy" + IFS= read -r -n 1 <&3 || true + mkdir -p "${IPE_DIR}/policies/acl_ipe_boot_policy" + printf '0\n' > "${IPE_DIR}/policies/acl_ipe_boot_policy/active" + cat <&3 > /dev/null + exec 3<&- + ) & + + local output + output="$(run_loader "${CASE_DIR}/credential.p7b.cred")" + wait + grep -Fq "Using IPE mode '${imds_mode}'." <<< "${output}" || + { echo "loader did not report requested mode '${imds_mode}'" >&2; return 1; } + [[ "$(<"${IPE_DIR}/enforce")" == "0" ]] + [[ "$(<"${IPE_DIR}/policies/acl_ipe_boot_policy/active")" == "1" ]] +} + +# ---- Test: 'enforcing' is reserved; loader logs an explicit unsupported +# diagnostic and falls back safely to inactive without failing boot ---- +test_enforcing_mode_safe_fallback() { + prepare_case enforcing-fallback + make_credential "${CASE_DIR}/credential.p7b.cred" + local h + h="$(sha256sum "${CASE_DIR}/credential.p7b.cred" | cut -d' ' -f1)" + printf 'flatcar.oem.id=azure acl.ipe.policy_sha256=%s\n' "${h}" > "${CASE_DIR}/cmdline" + + cat > "${CASE_DIR}/security-profile.sh" <<'EOF' +acl_security_profile() { printf '%s\n' 'ipe=enforcing'; } +acl_security_profile_value() { printf '%s\n' 'enforcing'; } +EOF + + local output + output="$(run_loader "${CASE_DIR}/credential.p7b.cred")" + grep -Fq "IPE mode 'enforcing' is not supported at runtime; leaving IPE inactive." <<< "${output}" || + { echo "missing explicit unsupported-mode diagnostic" >&2; return 1; } + grep -Fq "Using IPE mode 'off'." <<< "${output}" || + { echo "loader did not fall back to inactive mode" >&2; return 1; } + # Verify policy was loaded but not activated, and boot was not blocked. + [[ -s "${IPE_DIR}/new_policy" ]] + [[ "$(<"${IPE_DIR}/enforce")" == "9" ]] +} + + +# ---- Test: duplicate token → skip IPE and continue boot ---- +test_duplicate_token() { + prepare_case dup-token + local h="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + printf 'acl.ipe.policy_sha256=%s acl.ipe.policy_sha256=%s\n' "${h}" "${h}" > "${CASE_DIR}/cmdline" + assert_best_effort_skip "duplicate acl.ipe.policy_sha256 tokens" +} + +# ---- Test: malformed hash → skip IPE and continue boot ---- +test_malformed_hash() { + prepare_case malformed + printf 'acl.ipe.policy_sha256=GHIJ0123\n' > "${CASE_DIR}/cmdline" + assert_best_effort_skip "malformed acl.ipe.policy_sha256 value" +} + +# ---- Test: zero hash → skip IPE and continue boot ---- +test_zero_hash() { + prepare_case zero-hash + printf 'acl.ipe.policy_sha256=%s\n' "${ZERO_HASH}" > "${CASE_DIR}/cmdline" + assert_best_effort_skip "acl.ipe.policy_sha256 is all zeros" +} + +# ---- Test: missing credential → skip IPE and continue boot ---- +test_missing_credential() { + prepare_case missing-cred + printf 'acl.ipe.policy_sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n' > "${CASE_DIR}/cmdline" + assert_best_effort_skip "policy credential missing" "${CASE_DIR}/nonexistent.cred" +} + +# ---- Test: symlink credential → skip IPE and continue boot ---- +test_symlink_credential() { + prepare_case symlink-cred + make_credential "${CASE_DIR}/real.cred" + ln -sf "${CASE_DIR}/real.cred" "${CASE_DIR}/credential.p7b.cred" + # Verify the symlink was created (some platforms, e.g. Windows, may copy instead) + if [[ ! -L "${CASE_DIR}/credential.p7b.cred" ]]; then + echo "skipping test_symlink_credential (symlinks not supported)" + return 0 + fi + local h + h="$(sha256sum "${CASE_DIR}/real.cred" | cut -d' ' -f1)" + printf 'acl.ipe.policy_sha256=%s\n' "${h}" > "${CASE_DIR}/cmdline" + assert_best_effort_skip "credential must not be a symlink" \ + "${CASE_DIR}/credential.p7b.cred" +} + +# ---- Test: mismatched credential → skip IPE and continue boot ---- +test_mismatched_credential() { + prepare_case mismatch-cred + make_credential "${CASE_DIR}/credential.p7b.cred" + # Use a hash that won't match + printf 'acl.ipe.policy_sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n' > "${CASE_DIR}/cmdline" + assert_best_effort_skip "credential SHA-256 mismatch" \ + "${CASE_DIR}/credential.p7b.cred" +} + +# ---- Test: missing IPE interface → skip IPE and continue boot ---- +test_missing_ipe_interface() { + prepare_case no-ipe + make_credential "${CASE_DIR}/credential.p7b.cred" + local h + h="$(sha256sum "${CASE_DIR}/credential.p7b.cred" | cut -d' ' -f1)" + printf 'acl.ipe.policy_sha256=%s\n' "${h}" > "${CASE_DIR}/cmdline" + # Remove the IPE directory to simulate missing kernel support + rm -rf "${IPE_DIR}" + mkdir "${IPE_DIR}" + assert_best_effort_skip "IPE securityfs interface missing" \ + "${CASE_DIR}/credential.p7b.cred" +} + +# ---- Test: rejected kernel write → skip IPE and continue boot ---- +test_rejected_write() { + prepare_case rejected-write + make_credential "${CASE_DIR}/credential.p7b.cred" + local h + h="$(sha256sum "${CASE_DIR}/credential.p7b.cred" | cut -d' ' -f1)" + printf 'acl.ipe.policy_sha256=%s\n' "${h}" > "${CASE_DIR}/cmdline" + # Make new_policy a directory to simulate write rejection + rm "${IPE_DIR}/new_policy" + mkdir "${IPE_DIR}/new_policy" + assert_best_effort_skip "kernel rejected policy load" \ + "${CASE_DIR}/credential.p7b.cred" +} + +# ---- Test: preexisting policy → skip IPE and continue boot ---- +test_preexisting_ambiguity() { + prepare_case preexisting + make_credential "${CASE_DIR}/credential.p7b.cred" + local h + h="$(sha256sum "${CASE_DIR}/credential.p7b.cred" | cut -d' ' -f1)" + printf 'acl.ipe.policy_sha256=%s\n' "${h}" > "${CASE_DIR}/cmdline" + mkdir -p "${IPE_DIR}/policies/acl_ipe_boot_policy" + assert_best_effort_skip "IPE already has a loaded policy" \ + "${CASE_DIR}/credential.p7b.cred" +} + +# ---- Test: any differently named preexisting policy is also skipped ---- +test_other_preexisting_ambiguity() { + prepare_case other-preexisting + make_credential "${CASE_DIR}/credential.p7b.cred" + local h + h="$(sha256sum "${CASE_DIR}/credential.p7b.cred" | cut -d' ' -f1)" + printf 'acl.ipe.policy_sha256=%s\n' "${h}" > "${CASE_DIR}/cmdline" + mkdir -p "${IPE_DIR}/policies/another_policy" + assert_best_effort_skip "IPE already has a loaded policy" \ + "${CASE_DIR}/credential.p7b.cred" +} + +# ---- Test: missing runtime mode helper → skip IPE and continue boot ---- +test_missing_security_profile_helper() { + prepare_case missing-profile-helper + make_credential "${CASE_DIR}/credential.p7b.cred" + local h + h="$(sha256sum "${CASE_DIR}/credential.p7b.cred" | cut -d' ' -f1)" + printf 'acl.ipe.policy_sha256=%s\n' "${h}" > "${CASE_DIR}/cmdline" + rm "${CASE_DIR}/security-profile.sh" + assert_best_effort_skip "security profile helper is missing or unreadable" \ + "${CASE_DIR}/credential.p7b.cred" +} + +# ---- Test: incomplete runtime mode helper → skip IPE and continue boot ---- +test_incomplete_security_profile_helper() { + prepare_case incomplete-profile-helper + make_credential "${CASE_DIR}/credential.p7b.cred" + local h + h="$(sha256sum "${CASE_DIR}/credential.p7b.cred" | cut -d' ' -f1)" + printf 'acl.ipe.policy_sha256=%s\n' "${h}" > "${CASE_DIR}/cmdline" + cat > "${CASE_DIR}/security-profile.sh" <<'EOF' +acl_security_profile() { printf '%s\n' 'ipe=off'; } +EOF + assert_best_effort_skip "security profile helper is missing required functions" \ + "${CASE_DIR}/credential.p7b.cred" +} + +# ---- Test: IMDS failure after validation ---- +test_imds_failure_after_validation() { + ( + source "${PROFILE_HELPER}" + ACL_SECURITY_PROFILE_CACHE="${TMP_DIR}/node-security-profile" + ACL_SECURITY_PROFILE_FAILURE_CACHE="${ACL_SECURITY_PROFILE_CACHE}.failed" + attempts="${TMP_DIR}/imds-attempts" + + systemctl() { :; } + sleep() { :; } + acl_usrbin() { + printf x >> "${attempts}" + return 1 + } + + ! acl_security_profile >/dev/null 2>&1 + [[ "$(wc -c < "${attempts}")" -eq 30 ]] + [[ -e "${ACL_SECURITY_PROFILE_FAILURE_CACHE}" ]] + ! acl_security_profile >/dev/null 2>&1 + [[ "$(wc -c < "${attempts}")" -eq 30 ]] + ) +} + +# ---- Static assertions: Azure-scoped, best-effort service wiring ---- +test_best_effort_service_wiring() { + local module_setup="${SCRIPT_DIR}/build_library/rpm/additional_files/dracut-acl-ipe-load/module-setup.sh" + local service="${SCRIPT_DIR}/build_library/rpm/additional_files/dracut-acl-ipe-load/acl-ipe-load.service" + + ! grep -Fq 'initrd-switch-root.service' "${module_setup}" || + { echo "module-setup.sh installs a switch-root dependency" >&2; return 1; } + # sha256sum must be installed in initramfs + grep -Fq 'sha256sum' "${module_setup}" || + { echo "module-setup.sh missing sha256sum binary install" >&2; return 1; } + + grep -Fxq 'Before=initrd-parse-etc.service' "${service}" || + { echo "service has unexpected initrd ordering" >&2; return 1; } + ! grep -Fq 'initrd-switch-root.service' "${service}" || + { echo "service gates initrd-switch-root.service" >&2; return 1; } + grep -Fxq 'ConditionVirtualization=microsoft' "${service}" || + { echo "service missing Microsoft virtualization condition" >&2; return 1; } + grep -Fxq 'ConditionKernelCommandLine=flatcar.oem.id=azure' "${service}" || + { echo "service missing Azure kernel command-line condition" >&2; return 1; } + ! grep -Fq 'OnFailure=' "${service}" || + { echo "service has fail-closed OnFailure behavior" >&2; return 1; } + # No RequiresMountsFor + ! grep -Fq 'RequiresMountsFor' "${service}" || + { echo "service has RequiresMountsFor" >&2; return 1; } + # StandardError for explicit console/journal failure output + grep -Fq 'StandardError=journal+console' "${service}" || + { echo "service missing StandardError=journal+console" >&2; return 1; } +} + +test_absent_token +test_missing_cmdline +test_valid_inactive_load +test_valid_disabled_alias_inactive +test_valid_permissive +test_valid_audit_alias_active +test_enforcing_mode_safe_fallback +test_duplicate_token +test_malformed_hash +test_zero_hash +test_missing_credential +test_symlink_credential +test_mismatched_credential +test_missing_ipe_interface +test_rejected_write +test_preexisting_ambiguity +test_other_preexisting_ambiguity +test_missing_security_profile_helper +test_incomplete_security_profile_helper +test_imds_failure_after_validation +test_best_effort_service_wiring + +echo "IPE loader runtime mode tests passed" diff --git a/acl/tests/ipe/offline/test-ipe-mode-toggle-logic.sh b/acl/tests/ipe/offline/test-ipe-mode-toggle-logic.sh new file mode 100755 index 00000000000..379b75f1612 --- /dev/null +++ b/acl/tests/ipe/offline/test-ipe-mode-toggle-logic.sh @@ -0,0 +1,86 @@ +#!/bin/bash +# Offline unit tests for the pure (non-VM) logic in +# run-ipe-mode-toggle-test.sh: the acl-node-security-profile tag -> expected +# runtime mode mapping, and the 'enforcing must never be observed' safety +# assertions. The reboot/IMDS orchestration itself requires a live Azure VM +# and is exercised by run-ipe-mode-toggle-test.sh directly in pipeline runs. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)" +export SCRIPT_DIR + +# Sourcing (rather than executing) skips main() — see the +# BASH_SOURCE-vs-$0 guard at the bottom of the toggle test script — while +# still loading the exact same helper functions it uses at runtime. +source "${SCRIPT_DIR}/acl/tests/ipe/run-ipe-mode-toggle-test.sh" + +test_expected_mode_for_tag() { + local case_spec input expected actual + for case_spec in "disabled:off" "off:off" ":off" "audit:permissive" \ + "permissive:permissive" "enforcing:off" "bogus:off"; do + input="${case_spec%%:*}" + expected="${case_spec##*:}" + actual="$(expected_mode_for_tag "${input}")" + [[ "${actual}" == "${expected}" ]] || + { echo "expected_mode_for_tag('${input}') = '${actual}', want '${expected}'" >&2; return 1; } + done +} + +test_assert_ipe_mode_matches() { + get_ipe_mode() { echo "off"; } + assert_ipe_mode "off" + get_ipe_mode() { echo "permissive"; } + assert_ipe_mode "permissive" +} + +test_assert_ipe_mode_mismatch_fails() { + get_ipe_mode() { echo "off"; } + if assert_ipe_mode "permissive" 2>/dev/null; then + echo "assert_ipe_mode accepted a mismatched mode" >&2; return 1 + fi +} + +# ---- Safety: 'enforcing' observed anywhere must fail loudly, regardless +# of what was expected, and must never be silently accepted. ---- +test_assert_ipe_mode_rejects_enforcing() { + get_ipe_mode() { echo "enforcing"; } + local output + if output="$(assert_ipe_mode "off" 2>&1)"; then + echo "assert_ipe_mode accepted an observed 'enforcing' state" >&2; return 1 + fi + grep -Fq "CRITICAL: IPE is ENFORCING at runtime" <<< "${output}" || + { echo "missing CRITICAL enforcing diagnostic" >&2; return 1; } +} + +test_assert_ipe_not_enforcing() { + get_ipe_mode() { echo "off"; } + assert_ipe_not_enforcing + get_ipe_mode() { echo "permissive"; } + assert_ipe_not_enforcing + + get_ipe_mode() { echo "enforcing"; } + local output + if output="$(assert_ipe_not_enforcing 2>&1)"; then + echo "assert_ipe_not_enforcing accepted an observed 'enforcing' state" >&2; return 1 + fi + grep -Fq "CRITICAL: IPE is ENFORCING at runtime" <<< "${output}" || + { echo "missing CRITICAL enforcing diagnostic" >&2; return 1; } +} + +test_acl_security_profile_value_reused() { + # The toggle test reuses acl_security_profile_value() from the + # production security-profile helper; confirm it parses the way the + # in-guest loader expects. + [[ "$(acl_security_profile_value 'ipe=audit,foo=bar' 'ipe')" == "audit" ]] + [[ "$(acl_security_profile_value '' 'ipe')" == "" ]] +} + +test_expected_mode_for_tag +test_assert_ipe_mode_matches +test_assert_ipe_mode_mismatch_fails +test_assert_ipe_mode_rejects_enforcing +test_assert_ipe_not_enforcing +test_acl_security_profile_value_reused + +echo "IPE mode toggle logic tests passed" diff --git a/acl/tests/ipe/offline/test-ipe-policy-input.sh b/acl/tests/ipe/offline/test-ipe-policy-input.sh new file mode 100755 index 00000000000..0dcc46c6af9 --- /dev/null +++ b/acl/tests/ipe/offline/test-ipe-policy-input.sh @@ -0,0 +1,207 @@ +#!/bin/bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)" +TEST_DIR="${SCRIPT_DIR}/__build__/ipe-policy-test.$$" +mkdir -p "${TEST_DIR}" +export TMPDIR="${TEST_DIR}" +trap 'rm -rf "${TEST_DIR}"' EXIT + +source "${SCRIPT_DIR}/build_library/rpm/rpm_install.sh" +source "${SCRIPT_DIR}/build_library/rpm/ipe_verity.sh" + +info() { :; } +die() { echo "$*" >&2; exit 1; } +sudo() { + if [[ "$1" == "chroot" ]]; then + return 0 + fi + command "$@" +} + +export BUILD_LIBRARY_DIR="${SCRIPT_DIR}/build_library" +export BOOTLOADER_MODE=uki +policy="${BUILD_LIBRARY_DIR}/rpm/additional_files/ipe/acl-ipe-boot-policy.pol" + +prepare_case() { + local name="$1" + export BUILD_DIR="${TEST_DIR}/${name}-build" + CASE_ROOT="${TEST_DIR}/${name}-root" + mkdir -p "${BUILD_DIR}" "${CASE_ROOT}" +} + +test_ipe_disabled_by_default() { + prepare_case default + unset ACL_IPE_MODE ACL_IPE_SIGNING_MODE + + rpm_install_ipe_policy "${CASE_ROOT}" + + # Absent marker means disabled; no marker file, no staged artifacts. + [[ ! -f "${BUILD_DIR}/ipe-signing-mode" ]] + [[ ! -d "${BUILD_DIR}/acl-ipe-policy" ]] +} + +test_ipe_disabled_stages_nothing() { + prepare_case disabled-explicit + export ACL_IPE_MODE=disabled + unset ACL_IPE_SIGNING_MODE + + rpm_install_ipe_policy "${CASE_ROOT}" + + [[ ! -f "${BUILD_DIR}/ipe-signing-mode" ]] + [[ ! -d "${BUILD_DIR}/acl-ipe-policy" ]] +} + +test_disabled_cleanup_removes_stale_assets() { + prepare_case disabled-cleanup + mkdir -p "${BUILD_DIR}/acl-ipe-ephemeral" "${BUILD_DIR}/acl-ipe-policy" + printf 'stale-key\n' > "${BUILD_DIR}/acl-ipe-ephemeral/ca.key" + printf 'stale-cred\n' > "${BUILD_DIR}/acl-ipe-policy/acl-ipe-policy.p7b.cred" + printf 'ephemeral\n' > "${BUILD_DIR}/ipe-signing-mode" + + export ACL_IPE_MODE=disabled + unset ACL_IPE_SIGNING_MODE + + rpm_install_ipe_policy "${CASE_ROOT}" + + [[ ! -f "${BUILD_DIR}/ipe-signing-mode" ]] + [[ ! -d "${BUILD_DIR}/acl-ipe-policy" ]] + [[ ! -d "${BUILD_DIR}/acl-ipe-ephemeral" ]] +} + +test_audit_ephemeral_stages_candidate() { + prepare_case audit-ephemeral + export ACL_IPE_MODE=audit + export ACL_IPE_SIGNING_MODE=ephemeral + + rpm_install_ipe_policy "${CASE_ROOT}" + + [[ -s "${BUILD_DIR}/acl-ipe-ephemeral/ca.key" ]] + [[ -s "${BUILD_DIR}/acl-ipe-policy/acl-ipe-policy.p7b.cred" ]] + [[ ! -e "${BUILD_DIR}/acl-ipe-policy/acl-ipe-boot-policy.pol" ]] + # Verify the CMS wraps the canonical policy + openssl smime -verify -inform der -binary \ + -in "${BUILD_DIR}/acl-ipe-policy/acl-ipe-policy.p7b.cred" \ + -noverify -out "${TEST_DIR}/verified.pol" >/dev/null 2>&1 + cmp -s "${policy}" "${TEST_DIR}/verified.pol" + [[ "$(<"${BUILD_DIR}/ipe-signing-mode")" == "ephemeral" ]] +} + +test_audit_esrp_stages_candidate() { + prepare_case audit-esrp + export ACL_IPE_MODE=audit + export ACL_IPE_SIGNING_MODE=esrp + + rpm_install_ipe_policy "${CASE_ROOT}" + + [[ -s "${BUILD_DIR}/acl-ipe-policy/acl-ipe-policy.p7b.cred" ]] + [[ -s "${BUILD_DIR}/acl-ipe-policy/acl-ipe-boot-policy.pol" ]] + # Verify the CMS wraps the canonical policy + openssl smime -verify -inform der -binary \ + -in "${BUILD_DIR}/acl-ipe-policy/acl-ipe-policy.p7b.cred" \ + -noverify -out "${TEST_DIR}/ext-verified.pol" >/dev/null 2>&1 + cmp -s "${policy}" "${TEST_DIR}/ext-verified.pol" + [[ "$(<"${BUILD_DIR}/ipe-signing-mode")" == "esrp" ]] +} + +test_enforcing_mode_rejected() { + prepare_case enforcing-rejected + export ACL_IPE_MODE=enforcing + + if (rpm_install_ipe_policy "${CASE_ROOT}") 2>/dev/null; then + echo "reserved 'enforcing' mode was accepted by the producer" >&2 + return 1 + fi + unset ACL_IPE_MODE +} + +test_verity_roothash_matches_kernel_input() { + local output="${TEST_DIR}/root-hash.txt" + local roothash="000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F" + + ipe_verity_write_roothash "${roothash}" "${output}" + [[ "$(<"${output}")" == "${roothash,,}" ]] + [[ "$(wc -c < "${output}")" -eq 64 ]] + + if ipe_verity_write_roothash "${roothash}00" "${output}"; then + echo "invalid root hash was serialized" >&2 + return 1 + fi +} + +test_uki_binds_policy_before_writing_cmdline() { + local uki_install="${SCRIPT_DIR}/build_library/rpm/uki_install.sh" + local declaration_line append_line write_line + + declaration_line="$(grep -nF 'local ipe_policy_hash_token=""' "${uki_install}" | cut -d: -f1)" + append_line="$(grep -nF 'cmdline+=" ${ipe_policy_hash_token}"' "${uki_install}" | cut -d: -f1)" + write_line="$(grep -nF 'echo "${cmdline}" > "${uki_temp_dir}/cmdline.txt"' "${uki_install}" | cut -d: -f1)" + + [[ -n "${declaration_line}" && -n "${append_line}" && -n "${write_line}" ]] + [[ "${declaration_line}" -lt "${append_line}" ]] + [[ "${append_line}" -lt "${write_line}" ]] + grep -Fq 'EFI/Linux/${uki_name}.extra.d/acl-ipe-policy.p7b.cred' "${uki_install}" +} + +test_vm_conversions_share_secure_boot_cert() { + local cert_dir="${TEST_DIR}/vm-shared-cert" + local first_cert="${TEST_DIR}/vm-first-cert.pem" + local image_to_vm="${SCRIPT_DIR}/image_to_vm.sh" + local ensure_line sign_line cert_arg_line + + "${SCRIPT_DIR}/build_library/rpm/ensure_ephemeral_cert.sh" "${cert_dir}" >/dev/null 2>&1 + cp "${cert_dir}/uki-signing-ca.pem" "${first_cert}" + "${SCRIPT_DIR}/build_library/rpm/ensure_ephemeral_cert.sh" "${cert_dir}" >/dev/null 2>&1 + cmp -s "${first_cert}" "${cert_dir}/uki-signing-ca.pem" + + ensure_line="$(grep -nF \ + '"${BUILD_LIBRARY_DIR}/rpm/ensure_ephemeral_cert.sh" "${ephemeral_cert_dir}"' \ + "${image_to_vm}" | cut -d: -f1)" + sign_line="$(grep -nF \ + '"${BUILD_LIBRARY_DIR}/rpm/sign_uki_ephemeral.sh" \' \ + "${image_to_vm}" | cut -d: -f1)" + cert_arg_line="$(grep -nFx \ + ' "${ephemeral_cert_dir}"' \ + "${image_to_vm}" | cut -d: -f1)" + + [[ -n "${ensure_line}" && -n "${sign_line}" && -n "${cert_arg_line}" ]] + [[ "${ensure_line}" -lt "${sign_line}" ]] + [[ "${sign_line}" -lt "${cert_arg_line}" ]] +} + +test_markerless_secure_boot_cert_remains_disabled() { + local build_script="${SCRIPT_DIR}/acl/build_rpm_image.sh" + eval "$(sed -n '/^load_artifact_ipe_signing_mode() {/,/^}/p' "${build_script}")" + configure_ipe_mode() { :; } + error() { echo "$*" >&2; } + + prepare_case markerless-secure-boot-cert + mkdir -p "${BUILD_DIR}/acl-ipe-ephemeral" + printf 'test-cert\n' > "${BUILD_DIR}/acl-ipe-ephemeral/uki-signing-ca.pem" + IPE_MODE_OVERRIDE_SET=false + ACL_IPE_MODE=audit + ACL_IPE_SIGNING_MODE=ephemeral + + load_artifact_ipe_signing_mode "${BUILD_DIR}" + [[ "${ACL_IPE_MODE}" == "disabled" ]] + + mkdir -p "${BUILD_DIR}/acl-ipe-policy" + if load_artifact_ipe_signing_mode "${BUILD_DIR}" 2>/dev/null; then + echo "markerless IPE policy assets were accepted" >&2 + return 1 + fi +} + +test_ipe_disabled_by_default +test_ipe_disabled_stages_nothing +test_disabled_cleanup_removes_stale_assets +test_audit_ephemeral_stages_candidate +test_audit_esrp_stages_candidate +test_enforcing_mode_rejected +test_verity_roothash_matches_kernel_input +test_uki_binds_policy_before_writing_cmdline +test_vm_conversions_share_secure_boot_cert +test_markerless_secure_boot_cert_remains_disabled + +echo "IPE policy input tests passed" diff --git a/acl/tests/ipe/offline/test-run-sdk-container-ipe-policy.sh b/acl/tests/ipe/offline/test-run-sdk-container-ipe-policy.sh new file mode 100755 index 00000000000..100306b00e9 --- /dev/null +++ b/acl/tests/ipe/offline/test-run-sdk-container-ipe-policy.sh @@ -0,0 +1,120 @@ +#!/bin/bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)" +TEST_DIR="$(mktemp -d)" +trap 'rm -rf "${TEST_DIR}"' EXIT + +mkdir -p "${TEST_DIR}/sdk_lib" +cp "${SCRIPT_DIR}/run_sdk_container" "${TEST_DIR}/run_sdk_container" + +cat > "${TEST_DIR}/sdk_lib/sdk_container_common.sh" <<'EOF' +docker_a=(docker) +is_podman=false + +get_git_version() { echo "1.0.0"; } +get_sdk_version_from_versionfile() { echo "1.0.0"; } +get_version_from_versionfile() { echo "1.0.0"; } +vernum_to_docker_image_version() { echo "$1"; } +create_versionfile() { :; } +setup_sdk_env() { :; } +setup_gsutil() { :; } +gnupg_ssh_gcloud_mount_opts() { :; } +yell() { :; } + +call_docker() { + local command="$1" + + printf '%s' "${command}" >> "${FAKE_DOCKER_LOG}" + shift + printf '\t%s' "$@" >> "${FAKE_DOCKER_LOG}" + printf '\n' >> "${FAKE_DOCKER_LOG}" + + if [[ "${command}" == "ps" && + "${FAKE_DOCKER_STATUS:-up}" == "up" ]]; then + printf 'Up 1 second\n' + fi +} +EOF + +assert_docker_call() { + local log="$1" expected="$2" arg + shift 2 + for arg in "$@"; do + expected+=$'\t'"${arg}" + done + grep -Fqx "${expected}" "${log}" +} + +run_container() { + local mode="$1" signing_mode="$2" log="$3" status="${4:-up}" + ( + cd "${TEST_DIR}" + ACL_IPE_MODE="${mode}" \ + ACL_IPE_SIGNING_MODE="${signing_mode}" \ + FAKE_DOCKER_LOG="${log}" \ + FAKE_DOCKER_STATUS="${status}" \ + ./run_sdk_container -C fake-sdk -n reused-container -- true + ) +} + +test_mode_forwarded() { + local log="${TEST_DIR}/mode.log" + local mode signing + for mode in disabled audit; do + for signing in ephemeral esrp; do + : > "${log}" + run_container "${mode}" "${signing}" "${log}" + grep -Fq $'\t-e\tACL_IPE_MODE='"${mode}"$'\t' "${log}" || + { echo "IPE mode ${mode} was not forwarded" >&2; return 1; } + grep -Fq $'\t-e\tACL_IPE_SIGNING_MODE='"${signing}"$'\t' "${log}" || + { echo "IPE signing mode ${signing} was not forwarded" >&2; return 1; } + done + done +} + +test_invalid_mode_rejected() { + local log="${TEST_DIR}/invalid.log" + if run_container "invalid" "ephemeral" "${log}" 2>/dev/null; then + echo "invalid mode was accepted" >&2; return 1 + fi +} + +test_invalid_signing_mode_rejected() { + local log="${TEST_DIR}/invalid-signing.log" + if run_container "audit" "invalid" "${log}" 2>/dev/null; then + echo "invalid signing mode was accepted" >&2; return 1 + fi +} + +test_enforcing_mode_rejected() { + local log="${TEST_DIR}/enforcing.log" + if run_container "enforcing" "ephemeral" "${log}" 2>/dev/null; then + echo "reserved 'enforcing' mode was accepted" >&2; return 1 + fi +} + +test_untagged_checkout_version_fallback() { + local repo="${TEST_DIR}/untagged" + mkdir -p "${repo}/sdk_container/.repo/manifests" + printf 'FLATCAR_VERSION="9999.0.0"\n' \ + > "${repo}/sdk_container/.repo/manifests/version.txt" + git -C "${repo}" init -q + git -C "${repo}" -c user.name=test -c user.email=test@example.com \ + -c commit.gpgsign=false commit --allow-empty -qm initial + + ( + cd "${repo}" + source "${SCRIPT_DIR}/sdk_lib/sdk_container_common.sh" + [[ "$(get_git_version)" == "9999.0.0" ]] + ) +} + +test_mode_forwarded +test_invalid_mode_rejected +test_invalid_signing_mode_rejected +test_enforcing_mode_rejected +test_untagged_checkout_version_fallback + +echo "run_sdk_container IPE mode tests passed" diff --git a/acl/tests/ipe/run-ipe-mode-toggle-test.sh b/acl/tests/ipe/run-ipe-mode-toggle-test.sh new file mode 100755 index 00000000000..4d462c176a6 --- /dev/null +++ b/acl/tests/ipe/run-ipe-mode-toggle-test.sh @@ -0,0 +1,240 @@ +#!/bin/bash +# Test reboot-based IPE mode selection through acl-node-security-profile. +# +# Canonical acl-node-security-profile values are 'disabled' and 'audit'; +# 'off' and 'permissive' remain supported runtime aliases (disabled==off, +# audit==permissive) and stay covered below for backward compatibility. +# 'enforcing' is reserved: it must never activate at runtime, and any +# assertion that observes it fails loudly and immediately (see +# assert_ipe_mode / assert_ipe_not_enforcing). +# +# This script accepts whatever acl-node-security-profile tag the VM was +# *created* with (e.g. a smoke-test pipeline tagging +# acl-node-security-profile=ipe=audit before first boot): Step 0 reads and +# validates that initial state without forcing a reset first, then Steps +# 1-4 exercise the canonical disabled/audit reboot toggle, and Step 5 +# re-exercises the legacy off/permissive aliases. + +set -euo pipefail + +source "${SCRIPT_DIR}/acl/validate/validate_common.sh" +source "${SCRIPT_DIR}/acl/tests/azure-security-profile-test-common.sh" +# Reuse the exact same key=value profile parser the in-guest loader uses, +# so the host-side test can never disagree with production parsing. +source "${SCRIPT_DIR}/build_library/rpm/additional_files/acl-node-security-profile.sh" + +POLICY_NAME="acl_ipe_boot_policy" +IPE_DIR="/sys/kernel/security/ipe" +POLICY_DIR="${IPE_DIR}/policies/${POLICY_NAME}" + +get_ipe_mode() { + local active enforce + if ! ssh_cmd "sudo test -r '${POLICY_DIR}/active'"; then + echo "off" + return 0 + fi + + active=$(ssh_cmd "sudo cat '${POLICY_DIR}/active'" 2>/dev/null | tr -d '[:space:]') + if [[ "$active" != "1" ]]; then + echo "off" + return 0 + fi + + enforce=$(ssh_cmd "sudo cat '${IPE_DIR}/enforce'" 2>/dev/null | tr -d '[:space:]') + case "$enforce" in + 0) echo "permissive" ;; + 1) echo "enforcing" ;; + *) echo "unknown:${enforce}" ;; + esac +} + +# Maps an acl-node-security-profile 'ipe' value (canonical or legacy alias) +# to the runtime state the loader is expected to produce. Mirrors +# build_library/rpm/additional_files/dracut-acl-ipe-load/acl-ipe-load.sh: +# disabled|off -> inactive, audit|permissive -> active/permissive, +# enforcing (reserved) and anything unrecognized -> inactive. +expected_mode_for_tag() { + case "$1" in + disabled|off|"") echo "off" ;; + audit|permissive) echo "permissive" ;; + *) echo "off" ;; + esac +} + +# CRITICAL, fails loudly and immediately: 'enforcing' must never be the +# observed runtime state, regardless of what was expected or requested. +assert_ipe_not_enforcing() { + local actual + actual=$(get_ipe_mode) + if [[ "$actual" == "enforcing" ]]; then + error "CRITICAL: IPE is ENFORCING at runtime; the reserved 'enforcing' value must never activate." + return 1 + fi + info "Confirmed IPE is not enforcing (actual='${actual}')" +} + +assert_ipe_mode() { + local expected="$1" actual + actual=$(get_ipe_mode) + if [[ "$actual" == "enforcing" ]]; then + error "CRITICAL: IPE is ENFORCING at runtime; the reserved 'enforcing' value must never activate." + return 1 + fi + if [[ "$actual" != "$expected" ]]; then + error "IPE mode is '${actual}' but expected '${expected}'" + return 1 + fi + info "IPE mode is '${actual}' (expected '${expected}')" +} + +assert_ipe_assets_present() { + local expected_active="${1:-0}" + local cmdline usr_hash expected_signature_path policy_hash policy_active + cmdline=$(ssh_cmd "cat /proc/cmdline") + + if [[ " ${cmdline} " == *" ipe.enforce="* ]]; then + error "IPE mode is still embedded in the kernel command line" + return 1 + fi + # Verify the policy hash token is present on cmdline + policy_hash="$( + tr ' ' '\n' <<< "${cmdline}" | + sed -n 's/^acl\.ipe\.policy_sha256=//p' | + head -n 1 + )" + if ! [[ "${policy_hash}" =~ ^[0-9a-f]{64}$ ]]; then + error "acl.ipe.policy_sha256 token is missing or malformed in UKI cmdline" + return 1 + fi + if ! ssh_cmd "sudo test -r '${POLICY_DIR}/active'"; then + error "IPE policy was not loaded" + return 1 + fi + policy_active="$( + ssh_cmd "sudo cat '${POLICY_DIR}/active'" 2>/dev/null | + tr -d '[:space:]' + )" + if [[ "${policy_active}" != "${expected_active}" ]]; then + error "IPE policy active state is '${policy_active}', expected '${expected_active}'" + return 1 + fi + usr_hash="$( + tr ' ' '\n' <<< "${cmdline}" | + sed -n 's/^usrhash=//p' | + head -n 1 | + tr '[:upper:]' '[:lower:]' + )" + if ! [[ "${usr_hash}" =~ ^[[:xdigit:]]{64}$ ]]; then + error "Valid /usr root hash is missing from the UKI" + return 1 + fi + expected_signature_path="/.extra/credentials/verity-usr-${usr_hash}.p7s.cred" + if [[ "${cmdline}" != *"root-hash-signature=${expected_signature_path}"* ]]; then + error "Hash-matched /usr root-hash signature companion is missing from the UKI" + return 1 + fi + info "IPE assets are present and policy active state is '${policy_active}'" +} + +run_permissive_validation() { + ssh "${SSH_OPTS[@]}" "${VM_SSH_USER}@${VM_IP}" \ + "sudo bash -s" \ + < "${SCRIPT_DIR}/acl/tests/ipe/run-ipe-permissive-test.sh" +} + +main() { + parse_validate_args "$@" + + section "IPE IMDS Reboot Toggle Test" + + if [[ "$VM_TYPE" != "azure" ]]; then + info "Skipping IPE toggle test (requires Azure VM with IMDS)" + exit 0 + fi + + read_vm_state + setup_ssh_opts + + info "VM: ${VM_NAME} (RG: ${VM_RG}, IP: ${VM_IP})" + if ! wait_for_ssh "$VM_IP" "$VM_SSH_TIMEOUT"; then + error "Cannot reach VM via SSH" + exit 1 + fi + + section "Step 0: Accept the VM's initial acl-node-security-profile state" + # Read whatever tag the VM was already created/booted with (e.g. a smoke + # pipeline pre-tagging acl-node-security-profile=ipe=audit) without + # forcing a reset first, and validate it is honored. + local initial_profile initial_ipe_value initial_expected expected_tag + expected_tag="${ACL_EXPECTED_IPE_MODE:-}" + case "${expected_tag}" in + disabled|audit|"") ;; + *) + error "Invalid ACL_EXPECTED_IPE_MODE: ${expected_tag}" + exit 1 + ;; + esac + if [[ -n "${expected_tag}" ]]; then + initial_profile="$(imds_security_profile 2>/dev/null)" || { + error "Could not read initial acl-node-security-profile from IMDS" + exit 1 + } + else + initial_profile="$(imds_security_profile 2>/dev/null || true)" + fi + initial_ipe_value="$(acl_security_profile_value "${initial_profile}" "ipe")" + if [[ -n "${expected_tag}" && "${initial_ipe_value}" != "${expected_tag}" ]]; then + error "Initial IPE tag is '${initial_ipe_value:-}' but expected '${expected_tag}'" + exit 1 + fi + initial_expected="$(expected_mode_for_tag "${initial_ipe_value}")" + info "Initial acl-node-security-profile: '${initial_profile:-}' (ipe='${initial_ipe_value:-}') -> expected IPE mode '${initial_expected}'" + assert_ipe_mode "${initial_expected}" + if [[ "${initial_expected}" == "permissive" ]]; then + assert_ipe_assets_present 1 + run_permissive_validation + else + assert_ipe_assets_present 0 + fi + + section "Step 1: Disable IPE through the canonical 'disabled' value" + set_security_profile_and_reboot "ipe=disabled" + assert_ipe_mode "off" + assert_ipe_assets_present + + section "Step 2: Verify the reserved 'enforcing' value is rejected and never activates" + set_security_profile_and_reboot "ipe=enforcing" + assert_ipe_not_enforcing + assert_ipe_mode "off" + assert_ipe_assets_present + + section "Step 3: Enable IPE through the canonical 'audit' value" + set_security_profile_and_reboot "ipe=audit,foo=bar" + assert_ipe_mode "permissive" + assert_ipe_assets_present 1 + run_permissive_validation + + section "Step 4: Disable IPE through the canonical 'disabled' value" + set_security_profile_and_reboot "ipe=disabled" + assert_ipe_mode "off" + assert_ipe_assets_present + + section "Step 5: Legacy alias coverage (off/permissive remain supported)" + set_security_profile_and_reboot "ipe=permissive,foo=bar" + assert_ipe_mode "permissive" + assert_ipe_assets_present 1 + run_permissive_validation + set_security_profile_and_reboot "ipe=off" + assert_ipe_mode "off" + assert_ipe_assets_present + + section "IPE IMDS Reboot Toggle Test Summary" + info "All reboot-based IPE toggle assertions passed" +} + +# Allow this script to be sourced (e.g. by an offline unit-test harness) +# without executing main, while preserving the exact same invocation +# contract when run directly. +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi diff --git a/acl/tests/ipe/run-ipe-permissive-test.sh b/acl/tests/ipe/run-ipe-permissive-test.sh new file mode 100755 index 00000000000..5ac239b8274 --- /dev/null +++ b/acl/tests/ipe/run-ipe-permissive-test.sh @@ -0,0 +1,129 @@ +#!/bin/bash +# Live-VM validation of IPE permissive mode inside a Secure Boot VM. + +set -euo pipefail + +POLICY_NAME="acl_ipe_boot_policy" +IPE_DIR="/sys/kernel/security/ipe" +POLICY_DIR="${IPE_DIR}/policies/${POLICY_NAME}" + +fail() { + echo "FAILED: $*" >&2 + exit 1 +} + +echo "=========================================" +echo "IPE Permissive Mode Validation Test" +echo "=========================================" +echo "" + +cmdline="$(cat /proc/cmdline)" +echo "Kernel command line: ${cmdline}" + +if [[ " ${cmdline} " == *" ipe.enforce="* ]]; then + fail "IPE mode must be selected at runtime, not by the signed kernel command line" +fi +usr_hash="$( + tr ' ' '\n' <<< "${cmdline}" | + sed -n 's/^usrhash=//p' | + head -n 1 | + tr '[:upper:]' '[:lower:]' +)" +if ! [[ "${usr_hash}" =~ ^[[:xdigit:]]{64}$ ]]; then + fail "could not read the /usr dm-verity SHA-256 root hash from the command line" +fi +verity_usr_options="$( + tr ' ' '\n' <<< "${cmdline}" | + sed -n 's/^systemd\.verity_usr_options=//p' | + head -n 1 +)" +verity_sig_path="$( + tr ',' '\n' <<< "${verity_usr_options}" | + sed -n 's/^root-hash-signature=//p' | + head -n 1 +)" +expected_verity_sig_path="/.extra/credentials/verity-usr-${usr_hash}.p7s.cred" +if [[ "${verity_sig_path}" != "${expected_verity_sig_path}" ]]; then + fail "signed /usr root-hash signature does not match the UKI root hash" +fi + +if [[ ! -r "${IPE_DIR}/enforce" ]]; then + fail "IPE securityfs interface is unavailable" +fi +if [[ "$(tr -d '[:space:]' < "${IPE_DIR}/enforce")" != "0" ]]; then + fail "IPE is not in permissive mode" +fi + +if [[ ! -d "${POLICY_DIR}" ]]; then + fail "policy ${POLICY_NAME} is not loaded" +fi +if [[ "$(tr -d '[:space:]' < "${POLICY_DIR}/active")" != "1" ]]; then + fail "policy ${POLICY_NAME} is not active" +fi + +policy="$(cat "${POLICY_DIR}/policy")" +grep -Fq "DEFAULT op=EXECUTE action=DENY" <<< "${policy}" || + fail "active policy does not deny untrusted execution by default" +grep -Fq "op=EXECUTE boot_verified=TRUE action=ALLOW" <<< "${policy}" || + fail "active policy does not trust boot-verified initramfs files" +grep -Fq "op=EXECUTE dmverity_signature=TRUE action=ALLOW" <<< "${policy}" || + fail "active policy does not trust verified dm-verity signatures" +if grep -Fq "dmverity_roothash=" <<< "${policy}"; then + fail "active policy must not contain dmverity_roothash rules" +fi + +verity_device="$(readlink -f /dev/mapper/usr 2>/dev/null || true)" +usr_verity_mounted=false +while IFS= read -r usr_source; do + usr_device="$(readlink -f "${usr_source}" 2>/dev/null || true)" + if [[ -n "${verity_device}" && "${usr_device}" == "${verity_device}" ]]; then + usr_verity_mounted=true + break + fi +done < <(findmnt -n -o SOURCE /usr 2>/dev/null || true) +if [[ "${usr_verity_mounted}" != "true" ]]; then + fail "/usr mount stack does not include the expected dm-verity device" +fi + +# An executable copied to writable storage matches the policy's deny default. +# It must still run in permissive mode while generating audit data. +probe="/var/tmp/acl-ipe-permissive-probe" +trap 'rm -f "${probe}"' EXIT +cp /usr/bin/true "${probe}" +chmod 0755 "${probe}" +"${probe}" || fail "untrusted execution was blocked in permissive mode" + +boot_logs="$( + { + dmesg 2>/dev/null || true + journalctl -b --no-pager 2>/dev/null || true + } | tail -n 20000 +)" +loader_errors="$( + grep -Ei \ + 'acl-ipe-load: (failed|warning)|root hash verification failed|root[- ]hash signature.*(failed|invalid|error)|failed to (set up|activate).*verity|ENOKEY|required key not available' \ + <<< "${boot_logs}" || true +)" +if [[ -n "${loader_errors}" ]]; then + echo "${loader_errors}" >&2 + fail "IPE or dm-verity boot errors were detected" +fi + +audit_event="$( + grep -F "${probe}" <<< "${boot_logs}" | + grep -E 'ipe_op=EXECUTE.*enforcing=0|enforcing=0.*ipe_op=EXECUTE' || + true +)" +if [[ -n "${audit_event}" ]]; then + echo "Observed permissive IPE audit event:" + echo "${audit_event}" +else + fail "the permissive probe succeeded without the expected IPE audit event" +fi + +echo "" +echo "IPE policy: ${POLICY_NAME}" +echo "IPE enforce state: 0 (permissive)" +echo "/usr dm-verity root hash: ${usr_hash}" +echo "/usr dm-verity root-hash signature: ${verity_sig_path} (per-UKI ESP companion)" +echo "SUCCESS: IPE is active in permissive mode with no detected boot errors" diff --git a/acl/tests/run-selinux-toggle-test.sh b/acl/tests/run-selinux-toggle-test.sh index 5866409fd02..910ffd3428b 100755 --- a/acl/tests/run-selinux-toggle-test.sh +++ b/acl/tests/run-selinux-toggle-test.sh @@ -5,124 +5,24 @@ set -euo pipefail source "${SCRIPT_DIR}/acl/validate/validate_common.sh" +source "${SCRIPT_DIR}/acl/tests/azure-security-profile-test-common.sh" # ── Helpers ──────────────────────────────────────────────────────── -SSH_OPTS=() - -setup_ssh_opts() { - SSH_OPTS=( - -o StrictHostKeyChecking=no - -o UserKnownHostsFile=/dev/null - -o BatchMode=yes - -o ConnectTimeout=10 - -i "$VM_SSH_KEY" - ) -} - -ssh_cmd() { - ssh "${SSH_OPTS[@]}" "${VM_SSH_USER}@${VM_IP}" "$@" -} - # Get current SELinux mode via getenforce. get_selinux_mode() { ssh_cmd "sudo getenforce" 2>/dev/null | tr '[:upper:]' '[:lower:]' } -# Read the acl-node-security-profile tag value as IMDS sees it from the VM. -# Returns non-zero if the IMDS request fails or the response cannot be parsed. -imds_tag() { - local raw - raw=$(ssh_cmd "curl -sf -H Metadata:true --noproxy '*' \ - 'http://169.254.169.254/metadata/instance/compute/tagsList?api-version=2021-02-01'" \ - 2>/dev/null) || return 1 - jq -r '.[] | select(.name=="acl-node-security-profile") | .value' <<<"$raw" -} - -# Read the kernel boot ID from the VM. Changes on every real boot. -boot_id() { - ssh_cmd 'cat /proc/sys/kernel/random/boot_id' 2>/dev/null -} - -# Set (or remove) the tag on the VM, then wait for in-guest IMDS to converge. -# -# Uses the generic ARM tag endpoint (`az tag update`) rather than -# `az vm update --set tags.…`. The latter round-trips the whole VM resource -# through the Compute RP, so it can fail on unrelated VM properties. -set_selinux_tag() { - local value="$1" - local vm_id - vm_id=$(az vm show --resource-group "$VM_RG" --name "$VM_NAME" --query id -o tsv) - if [[ -z "$value" ]]; then - info "Removing acl-node-security-profile tag..." - az tag update \ - --resource-id "$vm_id" \ - --operation delete \ - --tags "acl-node-security-profile=" \ - --output none - else - info "Setting acl-node-security-profile=${value}..." - az tag update \ - --resource-id "$vm_id" \ - --operation merge \ - --tags "acl-node-security-profile=${value}" \ - --output none - fi - info "Waiting for in-guest IMDS to report tag='${value:-}'..." - local deadline=$(( $(date +%s) + 60 )) seen - while (( $(date +%s) < deadline )); do - seen=$(imds_tag) && [[ "$seen" == "$value" ]] && return 0 - sleep 2 - done - error "IMDS did not converge to '${value:-}' within 60s" - return 1 -} - -# Reboot the VM and wait until the kernel boot_id changes — proves SSH is back -# AND it's the new kernel (the old sshd can briefly answer mid-shutdown). -reboot_and_wait() { - local old new - old=$(boot_id) || { error "Cannot read boot_id — VM unreachable?"; return 1; } - info "Rebooting VM ${VM_NAME} via SSH (old boot_id=${old})..." - ssh_cmd "sudo reboot" || true - local deadline=$(( $(date +%s) + VM_SSH_TIMEOUT )) - while (( $(date +%s) < deadline )); do - new=$(boot_id) && [[ "$new" != "$old" ]] && { - info "VM rebooted (new boot_id=${new})" - return 0 - } - sleep 2 - done - warn "VM did not come back after reboot within ${VM_SSH_TIMEOUT}s — capturing VM diagnostics" - - local diag_dir="${DIAGNOSTICS_DIR:-/tmp}" - mkdir -p "$diag_dir" - local prefix="${diag_dir}/$(date +%Y%m%d-%H%M%S)-${VM_NAME}" - az vm get-instance-view --resource-group "$VM_RG" --name "$VM_NAME" \ - --query 'instanceView.{statuses:statuses,vmAgent:vmAgent.statuses}' \ - -o json 2>&1 | tee "${prefix}-instance-view.json" || true - az vm boot-diagnostics get-boot-log --resource-group "$VM_RG" --name "$VM_NAME" 2>&1 \ - | jq -r . > "${prefix}-serial.log" || true - - info "Full serial log: ${prefix}-serial.log ($(wc -c <"${prefix}-serial.log") bytes); last 200 lines:" - tail -200 "${prefix}-serial.log" | sed 's/^/ [serial] /' || true - info "Diagnostics saved to ${prefix}-{instance-view.json,serial.log}" - error "VM did not come back after reboot" - return 1 -} - # Assert that SELinux is in the expected mode. assert_selinux_mode() { - local expected="$1" - local actual + local expected="$1" actual actual=$(get_selinux_mode) - if [[ "$actual" == "$expected" ]]; then - info "✅ SELinux mode is '${actual}' (expected '${expected}')" - return 0 - else + if [[ "$actual" != "$expected" ]]; then error "❌ SELinux mode is '${actual}' but expected '${expected}'" return 1 fi + info "✅ SELinux mode is '${actual}' (expected '${expected}')" } # ── Main ─────────────────────────────────────────────────────────── @@ -162,27 +62,18 @@ main() { # Step 2: Toggle to permissive (with extra k/v to exercise comma-separated parsing). section "Step 2: Toggle SELinux to permissive via IMDS tag" - set_selinux_tag "selinux=permissive,foo=bar" - reboot_and_wait - if ! assert_selinux_mode "permissive"; then - exit_code=1 - fi + set_security_profile_and_reboot "selinux=permissive,foo=bar" + assert_selinux_mode "permissive" || exit_code=1 # Step 3: Toggle back to enforcing. section "Step 3: Toggle SELinux back to enforcing via IMDS tag" - set_selinux_tag "selinux=enforcing" - reboot_and_wait - if ! assert_selinux_mode "enforcing"; then - exit_code=1 - fi + set_security_profile_and_reboot "selinux=enforcing" + assert_selinux_mode "enforcing" || exit_code=1 # Step 4: Clean up — remove the tag, reboot, verify mode stays enforcing. section "Step 4: Cleanup — remove tag and reboot" - set_selinux_tag "" - reboot_and_wait - if ! assert_selinux_mode "enforcing"; then - exit_code=1 - fi + set_security_profile_and_reboot "" + assert_selinux_mode "enforcing" || exit_code=1 # Summary section "SELinux Toggle Test Summary" diff --git a/acl/tests/test-azure-security-profile-test-common.sh b/acl/tests/test-azure-security-profile-test-common.sh new file mode 100755 index 00000000000..cb3ff2d60ba --- /dev/null +++ b/acl/tests/test-azure-security-profile-test-common.sh @@ -0,0 +1,34 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +COMMON_HELPER="${SCRIPT_DIR}/acl/tests/azure-security-profile-test-common.sh" +VALIDATE_COMMON="${SCRIPT_DIR}/acl/validate/validate_common.sh" + +source "${COMMON_HELPER}" + +VM_SSH_KEY="/tmp/test-vm-ssh-key" +setup_ssh_opts + +contains_ssh_option() { + local option="$1" + local index + + for ((index = 0; index < ${#SSH_OPTS[@]} - 1; index++)); do + if [[ "${SSH_OPTS[index]}" == "-o" && "${SSH_OPTS[index + 1]}" == "${option}" ]]; then + return 0 + fi + done + + return 1 +} + +contains_ssh_option "ServerAliveInterval=5" +contains_ssh_option "ServerAliveCountMax=2" + +grep -Fq 'timeout --signal=TERM --kill-after=5s 15s \' "${COMMON_HELPER}" +grep -Fq 'reboot_timeout="${VM_BOOT_TIMEOUT:-$VM_SSH_TIMEOUT}"' "${COMMON_HELPER}" +grep -Fq '"--ssh-timeout=${VM_SSH_TIMEOUT}"' "${VALIDATE_COMMON}" +grep -Fq '"--boot-timeout=${VM_BOOT_TIMEOUT}"' "${VALIDATE_COMMON}" + +echo "azure security profile helper timeout contract passed" diff --git a/acl/tests/test-rpm-provider-selection.sh b/acl/tests/test-rpm-provider-selection.sh new file mode 100755 index 00000000000..52fd2b9e48b --- /dev/null +++ b/acl/tests/test-rpm-provider-selection.sh @@ -0,0 +1,50 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +info() { :; } +warn() { :; } +error() { echo "$*" >&2; } +die() { echo "$*" >&2; return 1; } + +source "${SCRIPT_DIR}/build_library/rpm/package_catalog.sh" +source "${SCRIPT_DIR}/build_library/rpm/rpm_install.sh" + +MOCK_DEPENDENCY="" +get_all_dependencies() { + echo "${MOCK_DEPENDENCY}" +} + +CAPTURED_PACKAGES=() +rpm_install_package() { + shift + CAPTURED_PACKAGES=("$@") +} + +contains_package() { + local expected="$1" + local package + + for package in "${CAPTURED_PACKAGES[@]}"; do + [[ "${package}" == "${expected}" ]] && return 0 + done + + return 1 +} + +assert_provider_selection() { + MOCK_DEPENDENCY="$1" + CAPTURED_PACKAGES=() + + rpm_install_package_using_portage_name "/tmp/test-root" "coreos-base/coreos" + + [[ ${#CAPTURED_PACKAGES[@]} -eq 2 ]] + contains_package "gettext" + contains_package "libgomp" +} + +assert_provider_selection "app-misc/gettext" +assert_provider_selection "sys-devel/gettext" + +echo "RPM provider selection contract passed" diff --git a/acl/validate/validate_common.sh b/acl/validate/validate_common.sh index 6bc6ea51918..d12e4760f28 100755 --- a/acl/validate/validate_common.sh +++ b/acl/validate/validate_common.sh @@ -934,7 +934,12 @@ validate_main() { local host_failed=0 for script in "${RUN_HOST_SCRIPTS[@]}"; do info "Running host script: $script" - local host_args=("--vm-type=${VM_TYPE}" "--ssh-user=${VM_SSH_USER}") + local host_args=( + "--vm-type=${VM_TYPE}" + "--ssh-user=${VM_SSH_USER}" + "--ssh-timeout=${VM_SSH_TIMEOUT}" + "--boot-timeout=${VM_BOOT_TIMEOUT}" + ) [[ -n "${VM_SSH_KEY:-}" ]] && host_args+=("--ssh-key=${VM_SSH_KEY}") if SCRIPT_DIR="${SCRIPT_DIR}" bash "$script" "${host_args[@]}"; then info "Host script completed: $script" diff --git a/build_library/build_image_util.sh b/build_library/build_image_util.sh index b7c706a6eaa..8688f8f1228 100755 --- a/build_library/build_image_util.sh +++ b/build_library/build_image_util.sh @@ -752,6 +752,7 @@ EOF sudo chroot "${root_fs_dir}" bash -c "cd /usr/share/selinux/mcs && semodule -s mcs -i *.pp" elif [[ "${PACKAGE_SOURCE_MODE}" == "RPM" ]]; then rpm_configure_selinux "${root_fs_dir}" + rpm_install_ipe_policy "${root_fs_dir}" fi # Run tmpfiles once to make sure that /etc has everything in place before diff --git a/build_library/rpm/additional_files/acl-node-security-profile.sh b/build_library/rpm/additional_files/acl-node-security-profile.sh new file mode 100644 index 00000000000..93d3757da97 --- /dev/null +++ b/build_library/rpm/additional_files/acl-node-security-profile.sh @@ -0,0 +1,75 @@ +#!/bin/bash + +ACL_SECURITY_PROFILE_CACHE="/run/acl/node-security-profile" +ACL_SECURITY_PROFILE_FAILURE_CACHE="${ACL_SECURITY_PROFILE_CACHE}.failed" + +acl_usrbin() { + local cmd="$1" + shift + LD_LIBRARY_PATH=/sysusr/usr/lib64 /sysusr/usr/bin/"${cmd}" "$@" +} + +acl_security_profile() { + local cache_dir imds_tags security_profile temporary_cache temporary_failure_cache + local i + + if [[ -r "${ACL_SECURITY_PROFILE_CACHE}" ]]; then + printf '%s\n' "$(<"${ACL_SECURITY_PROFILE_CACHE}")" + return 0 + fi + if [[ -e "${ACL_SECURITY_PROFILE_FAILURE_CACHE}" ]]; then + echo "ACL: Using cached IMDS security profile failure" >&2 + return 1 + fi + + echo "ACL: Starting networkd for IMDS security profile check" >&2 + systemctl start --quiet systemd-networkd systemd-resolved 2>/dev/null || true + + imds_tags="" + for ((i = 1; i <= 30; i++)); do + if imds_tags="$( + acl_usrbin curl -sf -H "Metadata:true" --noproxy "*" --max-time 5 \ + "http://169.254.169.254/metadata/instance/compute/tagsList?api-version=2021-02-01" \ + 2>/dev/null + )"; then + break + fi + echo "ACL: IMDS not ready, retry ${i}/30" >&2 + sleep 1 + done + if [[ -z "${imds_tags}" ]]; then + echo "ACL: IMDS unreachable after 30 retries" >&2 + cache_dir="${ACL_SECURITY_PROFILE_FAILURE_CACHE%/*}" + mkdir -p "${cache_dir}" + temporary_failure_cache="${ACL_SECURITY_PROFILE_FAILURE_CACHE}.$$" + : > "${temporary_failure_cache}" + mv -f "${temporary_failure_cache}" "${ACL_SECURITY_PROFILE_FAILURE_CACHE}" + return 1 + fi + + security_profile="$( + echo "${imds_tags}" | + acl_usrbin jq -r '.[] | select(.name=="acl-node-security-profile") | .value' + )" + cache_dir="${ACL_SECURITY_PROFILE_CACHE%/*}" + mkdir -p "${cache_dir}" + temporary_cache="${ACL_SECURITY_PROFILE_CACHE}.$$" + printf '%s' "${security_profile}" > "${temporary_cache}" + mv -f "${temporary_cache}" "${ACL_SECURITY_PROFILE_CACHE}" + printf '%s\n' "${security_profile}" +} + +acl_security_profile_value() { + local profile="$1" wanted_key="$2" pair key value + local -a pairs + + IFS=',' read -ra pairs <<< "${profile}" + for pair in "${pairs[@]}"; do + IFS='=' read -r key value <<< "${pair}" + if [[ "${key}" == "${wanted_key}" ]]; then + echo "${value}" + return 0 + fi + done + return 0 +} diff --git a/build_library/rpm/additional_files/dracut-acl-ipe-load/acl-ipe-load.service b/build_library/rpm/additional_files/dracut-acl-ipe-load/acl-ipe-load.service new file mode 100644 index 00000000000..0619b874c3e --- /dev/null +++ b/build_library/rpm/additional_files/dracut-acl-ipe-load/acl-ipe-load.service @@ -0,0 +1,19 @@ +[Unit] +Description=Load and activate ACL IPE boot policy +Documentation=https://docs.kernel.org/admin-guide/LSM/ipe.html +DefaultDependencies=no +# Run during initrd while SELinux is not yet loaded. +After=initrd-setup-root.service acl-selinux-toggle.service +Before=initrd-parse-etc.service +ConditionVirtualization=microsoft +ConditionKernelCommandLine=flatcar.oem.id=azure + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/usr/bin/acl-ipe-load +StandardOutput=journal+console +StandardError=journal+console + +[Install] +WantedBy=initrd.target diff --git a/build_library/rpm/additional_files/dracut-acl-ipe-load/acl-ipe-load.sh b/build_library/rpm/additional_files/dracut-acl-ipe-load/acl-ipe-load.sh new file mode 100644 index 00000000000..8f7f6c030db --- /dev/null +++ b/build_library/rpm/additional_files/dracut-acl-ipe-load/acl-ipe-load.sh @@ -0,0 +1,114 @@ +#!/bin/bash +# Best-effort load and activate the UKI-bound ACL IPE policy during Azure initrd. +# systemd-stub exposes the credential at /.extra/credentials/. + +set -uo pipefail + +POLICY_NAME="${ACL_IPE_POLICY_NAME:-acl_ipe_boot_policy}" +IPE_DIR="${ACL_IPE_DIR:-/sys/kernel/security/ipe}" +CREDENTIAL_PATH="${ACL_IPE_CREDENTIAL_PATH:-/.extra/credentials/acl-ipe-policy.p7b.cred}" +CMDLINE_FILE="${ACL_IPE_CMDLINE_FILE:-/proc/cmdline}" +SECURITY_PROFILE_HELPER="${ACL_IPE_SECURITY_PROFILE_HELPER:-/usr/lib/acl/acl-node-security-profile.sh}" + +log() { echo "acl-ipe-load: $*" >&2; } +skip() { log "WARNING: $*; continuing boot without activating IPE."; exit 0; } +write_value() { + local path="$1" value="$2" + [[ -w "${path}" ]] && printf '%s\n' "${value}" > "${path}" 2>/dev/null +} + +if ! cmdline="$(cat "${CMDLINE_FILE}")"; then + skip "could not read kernel command line from ${CMDLINE_FILE}" +fi + +expected_hash="" +token_count=0 +for word in ${cmdline}; do + [[ "${word}" == acl.ipe.policy_sha256=* ]] || continue + expected_hash="${word#acl.ipe.policy_sha256=}" + token_count=$((token_count + 1)) +done + +case "${token_count}" in + 0) log "No acl.ipe.policy_sha256 token; IPE disabled for this image."; exit 0 ;; + 1) ;; + *) skip "duplicate acl.ipe.policy_sha256 tokens on cmdline (count=${token_count})" ;; +esac + +[[ "${expected_hash}" =~ ^[0-9a-f]{64}$ ]] || + skip "malformed acl.ipe.policy_sha256 value: ${expected_hash}" +[[ "${expected_hash}" != "0000000000000000000000000000000000000000000000000000000000000000" ]] || + skip "acl.ipe.policy_sha256 is all zeros" + +[[ -e "${CREDENTIAL_PATH}" ]] || skip "policy credential missing: ${CREDENTIAL_PATH}" +[[ ! -L "${CREDENTIAL_PATH}" ]] || skip "credential must not be a symlink: ${CREDENTIAL_PATH}" +[[ -f "${CREDENTIAL_PATH}" ]] || skip "credential is not a regular file: ${CREDENTIAL_PATH}" +[[ -s "${CREDENTIAL_PATH}" ]] || skip "credential is empty: ${CREDENTIAL_PATH}" + +actual_hash="$(sha256sum "${CREDENTIAL_PATH}" | cut -d' ' -f1)" +actual_hash="${actual_hash,,}" +[[ "${actual_hash}" == "${expected_hash}" ]] || + skip "credential SHA-256 mismatch: expected=${expected_hash} actual=${actual_hash}" +log "Credential SHA-256 verified: ${expected_hash}" + +if [[ ! -e "${IPE_DIR}/new_policy" ]]; then + mount -t securityfs none /sys/kernel/security 2>/dev/null || true +fi +[[ -e "${IPE_DIR}/new_policy" ]] || + skip "IPE securityfs interface missing; kernel does not support IPE" + +if compgen -G "${IPE_DIR}/policies/*" >/dev/null; then + skip "IPE already has a loaded policy; refusing ambiguous state" +fi + +cat "${CREDENTIAL_PATH}" > "${IPE_DIR}/new_policy" 2>/dev/null || + skip "kernel rejected policy load from ${CREDENTIAL_PATH}" +log "Loaded policy ${POLICY_NAME} into kernel IPE." + +[[ -r "${SECURITY_PROFILE_HELPER}" ]] || + skip "security profile helper is missing or unreadable: ${SECURITY_PROFILE_HELPER}" +# shellcheck source=/usr/lib/acl/acl-node-security-profile.sh +source "${SECURITY_PROFILE_HELPER}" || + skip "could not load security profile helper: ${SECURITY_PROFILE_HELPER}" +declare -F acl_security_profile >/dev/null || + skip "security profile helper is missing required functions" +declare -F acl_security_profile_value >/dev/null || + skip "security profile helper is missing required functions" + +mode="off" +if [[ " ${cmdline} " == *" flatcar.oem.id=azure "* ]]; then + if security_profile="$(acl_security_profile)"; then + requested_mode="$(acl_security_profile_value "${security_profile}" "ipe")" + case "${requested_mode}" in + disabled|off|audit|permissive) mode="${requested_mode}" ;; + enforcing) + log "IPE mode 'enforcing' is not supported at runtime; leaving IPE inactive." + mode="off" + ;; + "") ;; + *) log "Ignoring unrecognized IPE mode '${requested_mode}' from IMDS." ;; + esac + else + log "IMDS unavailable; leaving IPE inactive (policy loaded but not activated)." + fi +fi +log "Using IPE mode '${mode}'." + +case "${mode}" in + disabled|off) + log "IPE mode is ${mode}; policy loaded but activation skipped." + exit 0 + ;; +esac + +write_value "${IPE_DIR}/enforce" 0 || + skip "failed to set IPE enforce=0 (${mode})" +log "Set IPE enforcement to 0 (${mode}) at runtime." + +active_file="${IPE_DIR}/policies/${POLICY_NAME}/active" +write_value "${active_file}" 1 || skip "failed to activate ${POLICY_NAME}" +log "Activated policy ${POLICY_NAME}." + +policy_active="$(cat "${active_file}" 2>/dev/null || true)" +[[ "${policy_active}" == "1" ]] || + skip "policy ${POLICY_NAME} activation verification failed" diff --git a/build_library/rpm/additional_files/dracut-acl-ipe-load/module-setup.sh b/build_library/rpm/additional_files/dracut-acl-ipe-load/module-setup.sh new file mode 100644 index 00000000000..2946abe3814 --- /dev/null +++ b/build_library/rpm/additional_files/dracut-acl-ipe-load/module-setup.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# -*- mode: shell-script; indent-tabs-mode: nil; sh-basic-offset: 4; -*- +# ex: ts=8 sw=4 sts=4 et filetype=sh + +install() { + # acl-ipe-load needs sha256sum for credential verification and mount for + # securityfs. + inst_multiple -o mkdir mount mv cat sha256sum cut + + inst_simple "${moddir}/acl-node-security-profile.sh" \ + "/usr/lib/acl/acl-node-security-profile.sh" + + inst_script "${moddir}/acl-ipe-load.sh" \ + "/usr/bin/acl-ipe-load" + + inst_simple "${moddir}/acl-ipe-load.service" \ + "${systemdsystemunitdir}/acl-ipe-load.service" + + # Manually enable the unit -- dracut does not process [Install] sections. + mkdir -p "${initdir}/${systemdsystemunitdir}/initrd.target.wants" + ln -sf "../acl-ipe-load.service" \ + "${initdir}/${systemdsystemunitdir}/initrd.target.wants/acl-ipe-load.service" +} diff --git a/build_library/rpm/additional_files/dracut-acl-selinux-toggle/acl-selinux-toggle.sh b/build_library/rpm/additional_files/dracut-acl-selinux-toggle/acl-selinux-toggle.sh index 2d306b426d3..8bf89d96ccd 100644 --- a/build_library/rpm/additional_files/dracut-acl-selinux-toggle/acl-selinux-toggle.sh +++ b/build_library/rpm/additional_files/dracut-acl-selinux-toggle/acl-selinux-toggle.sh @@ -6,51 +6,18 @@ set -euo pipefail -# usrbin wrapper — access real-root userspace binaries from /sysusr/usr/bin -# with the correct library path, matching bootengine's initrd-setup-root. -function usrbin() { - local cmd="$1" - shift - LD_LIBRARY_PATH=/sysusr/usr/lib64 /sysusr/usr/bin/"${cmd}" "$@" -} +source /usr/lib/acl/acl-node-security-profile.sh -echo "ACL: Starting networkd for IMDS SELinux tag check" >&2 -systemctl start --quiet systemd-networkd systemd-resolved - -imds_tags="" -imds_reached=0 -for i in $(usrbin seq 30); do - if imds_tags=$(usrbin curl -sf -H "Metadata:true" --noproxy "*" --max-time 5 \ - "http://169.254.169.254/metadata/instance/compute/tagsList?api-version=2021-02-01" 2>/dev/null); then - imds_reached=1 - break - fi - echo "ACL: IMDS not ready, retry ${i}/30" >&2 - sleep 1 -done - -if [ "${imds_reached}" -ne 1 ]; then - echo "ACL: IMDS unreachable after 30 retries" >&2 +if ! security_profile="$(acl_security_profile)"; then exit 1 fi -security_profile=$(echo "${imds_tags}" | usrbin jq -r '.[] | select(.name=="acl-node-security-profile") | .value') - if [ -z "${security_profile}" ]; then echo "ACL: IMDS reached but no acl-node-security-profile tag set, leaving SELinux mode unchanged" >&2 exit 0 fi -# Extract the selinux value from the comma-separated k/v list. -selinux_mode="" -IFS=',' read -ra pairs <<< "${security_profile}" -for pair in "${pairs[@]}"; do - IFS='=' read -r key value <<< "${pair}" - if [ "${key}" = "selinux" ]; then - selinux_mode="${value}" - break - fi -done +selinux_mode="$(acl_security_profile_value "${security_profile}" "selinux")" case "${selinux_mode}" in permissive) diff --git a/build_library/rpm/additional_files/dracut-acl-selinux-toggle/module-setup.sh b/build_library/rpm/additional_files/dracut-acl-selinux-toggle/module-setup.sh index 2a817bffa5c..ace81a4f2e8 100644 --- a/build_library/rpm/additional_files/dracut-acl-selinux-toggle/module-setup.sh +++ b/build_library/rpm/additional_files/dracut-acl-selinux-toggle/module-setup.sh @@ -3,6 +3,11 @@ # ex: ts=8 sw=4 sts=4 et filetype=sh install() { + inst_multiple -o mkdir mv + + inst_simple "${moddir}/acl-node-security-profile.sh" \ + "/usr/lib/acl/acl-node-security-profile.sh" + inst_script "${moddir}/acl-selinux-toggle.sh" \ "/usr/bin/acl-selinux-toggle" diff --git a/build_library/rpm/additional_files/ipe/acl-ipe-boot-policy.pol b/build_library/rpm/additional_files/ipe/acl-ipe-boot-policy.pol new file mode 100644 index 00000000000..2046588114d --- /dev/null +++ b/build_library/rpm/additional_files/ipe/acl-ipe-boot-policy.pol @@ -0,0 +1,7 @@ +policy_name=acl_ipe_boot_policy policy_version=0.0.1 + +DEFAULT action=ALLOW + +DEFAULT op=EXECUTE action=DENY +op=EXECUTE boot_verified=TRUE action=ALLOW +op=EXECUTE dmverity_signature=TRUE action=ALLOW diff --git a/build_library/rpm/dracut_install.sh b/build_library/rpm/dracut_install.sh index 364ff0e91a4..5a8d95c3461 100644 --- a/build_library/rpm/dracut_install.sh +++ b/build_library/rpm/dracut_install.sh @@ -304,9 +304,36 @@ SETUP_EOF "${selinux_toggle_module}/acl-selinux-toggle.sh" sudo cp "${BUILD_LIBRARY_DIR}/rpm/additional_files/dracut-acl-selinux-toggle/acl-selinux-toggle.service" \ "${selinux_toggle_module}/acl-selinux-toggle.service" + sudo cp "${BUILD_LIBRARY_DIR}/rpm/additional_files/acl-node-security-profile.sh" \ + "${selinux_toggle_module}/acl-node-security-profile.sh" sudo chmod +x "${selinux_toggle_module}/module-setup.sh" sudo chmod +x "${selinux_toggle_module}/acl-selinux-toggle.sh" + # Optional ACL IPE policy loader. Capable images always carry the loader, + # but it leaves the policy inactive unless Azure IMDS requests a mode. + case "${ACL_IPE_CAPABLE:-false}" in + false) + ;; + true) + info "RPM mode: Creating acl-ipe-load dracut module" + local ipe_load_module="${root_fs_dir}/usr/lib/dracut/modules.d/99acl-ipe-load" + sudo mkdir -p "${ipe_load_module}" + sudo cp "${BUILD_LIBRARY_DIR}/rpm/additional_files/dracut-acl-ipe-load/module-setup.sh" \ + "${ipe_load_module}/module-setup.sh" + sudo cp "${BUILD_LIBRARY_DIR}/rpm/additional_files/dracut-acl-ipe-load/acl-ipe-load.sh" \ + "${ipe_load_module}/acl-ipe-load.sh" + sudo cp "${BUILD_LIBRARY_DIR}/rpm/additional_files/dracut-acl-ipe-load/acl-ipe-load.service" \ + "${ipe_load_module}/acl-ipe-load.service" + sudo cp "${BUILD_LIBRARY_DIR}/rpm/additional_files/acl-node-security-profile.sh" \ + "${ipe_load_module}/acl-node-security-profile.sh" + sudo chmod +x "${ipe_load_module}/module-setup.sh" + sudo chmod +x "${ipe_load_module}/acl-ipe-load.sh" + ;; + *) + die "Invalid ACL_IPE_CAPABLE: ${ACL_IPE_CAPABLE}" + ;; + esac + # NOTE: /etc overlay is handled by bootengine's 99setup-root/initrd-setup-root # We need to create the required files BEFORE dracut runs so they get included in initramfs diff --git a/build_library/rpm/ensure_ephemeral_cert.sh b/build_library/rpm/ensure_ephemeral_cert.sh new file mode 100755 index 00000000000..1da609bf113 --- /dev/null +++ b/build_library/rpm/ensure_ephemeral_cert.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Generate the per-build test certificate shared by the IPE policy, /usr +# root-hash signature, and UKI. Enrolling this certificate in UEFI db makes it +# available to the kernel through the .platform keyring. + +set -euo pipefail + +CERT_DIR="${1:?usage: ensure_ephemeral_cert.sh }" +KEY="${CERT_DIR}/ca.key" +CERT="${CERT_DIR}/uki-signing-ca.pem" + +if [[ -s "${KEY}" && -s "${CERT}" ]]; then + exit 0 +fi + +mkdir -p "${CERT_DIR}" +openssl req -x509 \ + -newkey rsa:2048 \ + -days 1 \ + -noenc \ + -keyout "${KEY}" \ + -out "${CERT}" \ + -subj "/CN=ACL Ephemeral Signing $(date +%Y%m%d%H%M%S)" \ + -sha256 \ + -addext "basicConstraints=CA:FALSE" \ + -addext "extendedKeyUsage=codeSigning" +chmod 600 "${KEY}" diff --git a/build_library/rpm/ipe_verity.sh b/build_library/rpm/ipe_verity.sh new file mode 100644 index 00000000000..1ebe7186065 --- /dev/null +++ b/build_library/rpm/ipe_verity.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +ipe_verity_write_roothash() { + local roothash="${1,,}" output="$2" + + [[ "${roothash}" =~ ^[0-9a-f]{64}$ ]] || return 1 + printf '%s' "${roothash}" > "${output}" +} diff --git a/build_library/rpm/package_catalog.yaml b/build_library/rpm/package_catalog.yaml index d857ba2c43b..24af3cb51e0 100644 --- a/build_library/rpm/package_catalog.yaml +++ b/build_library/rpm/package_catalog.yaml @@ -226,8 +226,14 @@ packages: app-crypt/ca-certificates-tools: ca-certificates-tools sys-apps/hwdata: hwdata sys-apps/kbd: kbd - app-misc/gettext: gettext - sys-devel/gettext: gettext + # Request libgomp by package name so DNF cannot satisfy gettext's runtime + # dependency with an unrelated package that bundles the same SONAME. + app-misc/gettext: + - gettext + - libgomp + sys-devel/gettext: + - gettext + - libgomp sys-apps/busybox: busybox sys-apps/groff: groff sys-apps/nvme-cli: nvme-cli @@ -710,4 +716,3 @@ packages: sys-libs/nss-usrfiles: SKIP sys-libs/tevent: SKIP sys-power/acpid: SKIP - diff --git a/build_library/rpm/rpm_install.sh b/build_library/rpm/rpm_install.sh index 5f5d626f8c3..8d7c33d8cab 100644 --- a/build_library/rpm/rpm_install.sh +++ b/build_library/rpm/rpm_install.sh @@ -725,8 +725,13 @@ rpm_install_package_using_portage_name() { local source=$(get_package_status "$dep") case "$source" in RPM) - local rpm_name=$(get_rpm_package_name "$dep") - [[ -n "$rpm_name" ]] && rpm_pkgs+=("$rpm_name") + local rpm_names + rpm_names=$(get_rpm_package_name "$dep") + if [[ -n "$rpm_names" ]]; then + local mapped_rpms=() + read -r -a mapped_rpms <<< "$rpm_names" + rpm_pkgs+=("${mapped_rpms[@]}") + fi ;; PORTAGE) portage_pkgs+=("$dep") @@ -759,7 +764,8 @@ rpm_install_package_using_portage_name() { if [[ ${#rpm_pkgs[@]} -gt 0 ]]; then info "Step 3: Installing RPM packages..." # Remove duplicates - local unique_rpm_pkgs=($(printf '%s\n' "${rpm_pkgs[@]}" | sort -u)) + local unique_rpm_pkgs=() + mapfile -t unique_rpm_pkgs < <(printf '%s\n' "${rpm_pkgs[@]}" | sort -u) rpm_install_package "${root_fs_dir}" "${unique_rpm_pkgs[@]}" || { error "Failed to install RPM packages during RPM mode installation" error "Root filesystem: ${root_fs_dir}" @@ -874,6 +880,118 @@ sslverify=1 EOF } +rpm_record_ipe_signing_mode() { + local ipe_signing_mode="$1" + [[ -n "${BUILD_DIR:-}" ]] || return 0 + printf '%s\n' "${ipe_signing_mode}" > "${BUILD_DIR}/ipe-signing-mode" || + die "RPM mode: failed to record IPE signing mode" +} + +rpm_clear_ipe_signing_mode() { + [[ -n "${BUILD_DIR:-}" ]] || return 0 + rm -f -- "${BUILD_DIR}/ipe-signing-mode" +} + +rpm_validate_ipe_policy_source() { + local policy_src="$1" + [[ -f "${policy_src}" ]] || + die "RPM mode: IPE policy source not found: ${policy_src}" + grep -Fq 'op=EXECUTE dmverity_signature=TRUE action=ALLOW' "${policy_src}" || + die "RPM mode: IPE policy does not trust verified dm-verity signatures" +} + +rpm_prepare_ipe_policy_artifact() { + local policy_src="$1" policy_sig="$2" cert_dir="$3" + local key="${cert_dir}/ca.key" + local cert="${cert_dir}/uki-signing-ca.pem" + + "${BUILD_LIBRARY_DIR}/rpm/ensure_ephemeral_cert.sh" "${cert_dir}" || + die "RPM mode: IPE could not ensure ephemeral signing cert" + if ! openssl smime -sign -binary \ + -in "${policy_src}" \ + -signer "${cert}" \ + -inkey "${key}" \ + -noattr -nodetach -nosmimecap \ + -outform der \ + -out "${policy_sig}" 2>/dev/null || [[ ! -s "${policy_sig}" ]]; then + die "RPM mode: IPE failed to sign policy" + fi +} + +rpm_verify_ipe_policy_artifact() { + local policy_src="$1" policy_sig="$2" verified_policy="$3" + if ! openssl smime -verify -inform der -binary \ + -in "${policy_sig}" -noverify \ + -out "${verified_policy}" >/dev/null 2>&1; then + die "RPM mode: IPE policy is not a valid attached DER PKCS#7 artifact" + fi + cmp -s "${policy_src}" "${verified_policy}" || + die "RPM mode: verified IPE policy content differs from source policy" +} + +rpm_install_ipe_policy() { + local root_fs_dir="$1" + local ipe_mode="${ACL_IPE_MODE:-disabled}" + + case "${ipe_mode}" in + disabled) + if [[ -n "${BUILD_DIR:-}" ]]; then + rm -rf -- "${BUILD_DIR}/acl-ipe-policy" "${BUILD_DIR}/acl-ipe-ephemeral" + fi + rpm_clear_ipe_signing_mode + return 0 + ;; + audit) ;; + *) die "Invalid ACL_IPE_MODE: ${ipe_mode}" ;; + esac + + local ipe_signing_mode="${ACL_IPE_SIGNING_MODE:-ephemeral}" + case "${ipe_signing_mode}" in + ephemeral|esrp) ;; + *) die "Invalid ACL_IPE_SIGNING_MODE: ${ipe_signing_mode}" ;; + esac + + [[ "${BOOTLOADER_MODE:-uki}" == "uki" ]] || + die "RPM mode: IPE requires the UKI bootloader" + [[ -n "${BUILD_DIR:-}" ]] || + die "RPM mode: BUILD_DIR is not set; cannot create IPE signing assets" + + local policy_src="${BUILD_LIBRARY_DIR}/rpm/additional_files/ipe/acl-ipe-boot-policy.pol" + + rpm_validate_ipe_policy_source "${policy_src}" + + local cert_dir + cert_dir="$(readlink -f "${BUILD_DIR}")/acl-ipe-ephemeral" + + # Stage directory for Pipelines collector and uki_install.sh. + local stage_dir="${BUILD_DIR}/acl-ipe-policy" + rm -rf -- "${stage_dir}" + mkdir -p "${stage_dir}" + + local policy_sig="${stage_dir}/acl-ipe-policy.p7b.cred" + local staged_raw="${stage_dir}/acl-ipe-boot-policy.pol" + local work_dir verified_policy + work_dir="$(mktemp -d)" + verified_policy="${work_dir}/acl-ipe-boot-policy.verified.pol" + + rpm_prepare_ipe_policy_artifact \ + "${policy_src}" "${policy_sig}" "${cert_dir}" + rpm_verify_ipe_policy_artifact \ + "${policy_src}" "${policy_sig}" "${verified_policy}" + + # ESRP replaces the candidate CMS downstream and therefore needs the + # canonical source. Ephemeral builds consume only the verified CMS. + if [[ "${ipe_signing_mode}" == "esrp" ]]; then + cp "${policy_src}" "${staged_raw}" || + die "RPM mode: failed to stage raw IPE policy" + fi + + rm -rf "${work_dir}" + rpm_record_ipe_signing_mode "${ipe_signing_mode}" + + info "RPM mode: Staged ${ipe_signing_mode} IPE policy candidate at ${stage_dir}" +} + rpm_configure_selinux() { local root_fs_dir="$1" @@ -1056,4 +1174,10 @@ export -f rpm_download_packages export -f rpm_use_official_repos export -f rpm_cleanup_build_dir export -f rpm_umount_pseudofs +export -f rpm_record_ipe_signing_mode +export -f rpm_clear_ipe_signing_mode +export -f rpm_validate_ipe_policy_source +export -f rpm_prepare_ipe_policy_artifact +export -f rpm_verify_ipe_policy_artifact +export -f rpm_install_ipe_policy export -f rpm_configure_selinux diff --git a/build_library/rpm/sign_uki_ephemeral.sh b/build_library/rpm/sign_uki_ephemeral.sh index 8594764e50e..e8f8848e921 100755 --- a/build_library/rpm/sign_uki_ephemeral.sh +++ b/build_library/rpm/sign_uki_ephemeral.sh @@ -14,10 +14,12 @@ # - OVMF Secure Boot db (for QEMU testing via virt-fw-vars) # - Azure gallery image (for Azure VM testing via securityProfile) # -# The private key is deleted after signing. +# A standalone private key is deleted after signing. When an existing per-build +# key is supplied, it is retained for other image formats built from the same +# source image. # # Usage: -# sign_uki_ephemeral.sh +# sign_uki_ephemeral.sh [ephemeral-cert-dir] # # Requirements (all available in the SDK container): # - openssl (key and certificate generation) @@ -25,8 +27,11 @@ set -euo pipefail -ESP_DIR="${1:?Usage: sign_uki_ephemeral.sh }" -CERT_OUTPUT_DIR="${2:?Usage: sign_uki_ephemeral.sh }" +ESP_DIR="${1:?Usage: sign_uki_ephemeral.sh [ephemeral-cert-dir]}" +CERT_OUTPUT_DIR="${2:?Usage: sign_uki_ephemeral.sh [ephemeral-cert-dir]}" +# Optional directory holding the per-build certificate shared by the UKI, +# verity root hash, and IPE policy candidate. +EPHEMERAL_CERT_DIR="${3:-}" CERT_NAME="uki-signing-ca.pem" @@ -64,25 +69,39 @@ fi mkdir -p "${CERT_OUTPUT_DIR}" -# Generate ephemeral key + certificate +# Scratch dir for intermediate EFI signing artifacts. WORK_DIR=$(mktemp -d) trap 'rm -rf "${WORK_DIR}"' EXIT -KEY_FILE="${WORK_DIR}/ca.key" CERT_FILE="${CERT_OUTPUT_DIR}/${CERT_NAME}" -info "Generating ephemeral signing certificate..." -openssl req -x509 \ - -newkey rsa:2048 \ - -days 1 \ - -noenc \ - -keyout "${KEY_FILE}" \ - -out "${CERT_FILE}" \ - -subj "/CN=ACL Ephemeral Signing $(date +%Y%m%d%H%M%S)" \ - -sha256 \ - -addext "basicConstraints=CA:FALSE" \ - -addext "extendedKeyUsage=codeSigning" \ - 2>&1 +if [[ -n "${EPHEMERAL_CERT_DIR}" ]]; then + # Reuse the cert that signed the detached verity root hash. The same cert + # must sign the UKI and be enrolled in the VM db so the kernel can verify + # that signature against .platform. + if [[ ! -s "${EPHEMERAL_CERT_DIR}/ca.key" || ! -s "${EPHEMERAL_CERT_DIR}/${CERT_NAME}" ]]; then + error "Shared ephemeral cert not found in ${EPHEMERAL_CERT_DIR} (expected from the image build)" + exit 1 + fi + KEY_FILE="${EPHEMERAL_CERT_DIR}/ca.key" + cp -f "${EPHEMERAL_CERT_DIR}/${CERT_NAME}" "${CERT_FILE}" + info "Reusing shared per-build ephemeral cert for UKI, verity, and IPE assets" +else + # No shared cert (non-IPE build): generate a standalone throwaway cert. + KEY_FILE="${WORK_DIR}/ca.key" + info "Generating ephemeral signing certificate..." + openssl req -x509 \ + -newkey rsa:2048 \ + -days 1 \ + -noenc \ + -keyout "${KEY_FILE}" \ + -out "${CERT_FILE}" \ + -subj "/CN=ACL Ephemeral Signing $(date +%Y%m%d%H%M%S)" \ + -sha256 \ + -addext "basicConstraints=CA:FALSE" \ + -addext "extendedKeyUsage=codeSigning" \ + 2>&1 +fi info "Certificate: ${CERT_FILE}" @@ -124,6 +143,6 @@ for efi_file in "${efi_files[@]}"; do sign_efi_file "${efi_file}" done -# Private key is in WORK_DIR which is cleaned up by the trap. +# Any standalone private key is in WORK_DIR and is cleaned up by the trap. info "Signing complete. ${#efi_files[@]} file(s) signed." info "Public certificate: ${CERT_FILE}" diff --git a/build_library/rpm/uki_install.sh b/build_library/rpm/uki_install.sh index a4ea402fa86..d25d7fa6477 100755 --- a/build_library/rpm/uki_install.sh +++ b/build_library/rpm/uki_install.sh @@ -28,6 +28,15 @@ switch_to_strict_mode # Must be sourced after flags are parsed . "${BUILD_LIBRARY_DIR}/toolchain_util.sh" || exit 1 . "${BUILD_LIBRARY_DIR}/board_options.sh" || exit 1 +. "${BUILD_LIBRARY_DIR}/rpm/ipe_verity.sh" || exit 1 + +IPE_CAPABLE="${ACL_IPE_CAPABLE:-false}" +case "${IPE_CAPABLE}" in + true|false) ;; + *) + die_notrace "ACL_IPE_CAPABLE must be true or false (got: ${IPE_CAPABLE})" + ;; +esac # Determine EFI architecture suffix case "${FLAGS_target}" in @@ -49,6 +58,48 @@ case "${FLAGS_target}" in ;; esac +# systemd-stub transports the IPE policy and root-hash signatures from the +# UKI's .extra.d companion directory into the initramfs at boot. Build-time +# images use one ephemeral certificate for the UKI and both credentials. +# Definition 5425 replaces all three signatures for esrp-mode images. +IPE_VERITY_SIG_SOURCE="" +IPE_VERITY_SIG_PATH="" + +_uki_ipe_prepare_verity_signature() { + local work_dir="$1" cert_dir="$2" roothash="$3" + local cert="${cert_dir}/uki-signing-ca.pem" + local key="${cert_dir}/ca.key" + local content="${work_dir}/usr-roothash.txt" + local filename="verity-usr-${roothash}.p7s.cred" + local output_sig="${work_dir}/${filename}" + + IPE_VERITY_SIG_SOURCE="" + IPE_VERITY_SIG_PATH="" + + if ! "${BUILD_LIBRARY_DIR}/rpm/ensure_ephemeral_cert.sh" "${cert_dir}" || + ! ipe_verity_write_roothash "${roothash}" "${content}"; then + error "UKI/RPM: failed to prepare /usr verity signing input" + return 1 + fi + if ! openssl smime -sign -noattr -binary \ + -in "${content}" -signer "${cert}" -inkey "${key}" \ + -outform der -out "${output_sig}" 2>/dev/null || + [[ ! -s "${output_sig}" ]]; then + error "UKI/RPM: failed to sign /usr verity root hash" + return 1 + fi + if ! openssl smime -verify -inform der -binary \ + -in "${output_sig}" -content "${content}" \ + -certfile "${cert}" -noverify >/dev/null 2>&1; then + error "UKI/RPM: /usr verity root-hash signature failed self-verify" + return 1 + fi + + IPE_VERITY_SIG_SOURCE="${output_sig}" + IPE_VERITY_SIG_PATH="/.extra/credentials/${filename}" + info "UKI/RPM: signed /usr verity root hash ($(wc -c < "${output_sig}") B PKCS#7)" +} + uki_install_rpm() { info "UKI/RPM mode: Installing systemd-boot and shim to BOARD_ROOT" @@ -151,19 +202,63 @@ OSREL info "UKI/RPM: USR-A uuid=${usr_a_uuid} verity hash-offset=${verity_hash_offset}" - local cmdline="" + local usr_hash="" if [[ ${FLAGS_verity} -eq ${FLAGS_TRUE} ]]; then - local usr_hash="" if [[ -n "${FLAGS_verity_hash}" && -f "${FLAGS_verity_hash}" ]]; then - usr_hash=$(cat "${FLAGS_verity_hash}") + usr_hash="$(tr -d '[:space:]' < "${FLAGS_verity_hash}")" + if ! [[ "${usr_hash}" =~ ^[[:xdigit:]]{64}$ ]]; then + die "UKI/RPM: invalid SHA-256 /usr verity root hash" + fi + usr_hash="${usr_hash,,}" info "UKI/RPM: Verity hash = ${usr_hash}" else die "UKI/RPM: Verity enabled but no hash file at ${FLAGS_verity_hash}" fi + fi + if [[ "${IPE_CAPABLE}" == "true" && ${FLAGS_verity} -ne ${FLAGS_TRUE} ]]; then + die "UKI/RPM: IPE assets require a /usr dm-verity root hash" + fi + + if [[ "${IPE_CAPABLE}" == "true" ]]; then + local build_dir cert_dir + build_dir="$(readlink -f "$(dirname "${FLAGS_disk_image}")")" + cert_dir="${build_dir}/acl-ipe-ephemeral" + if ! _uki_ipe_prepare_verity_signature \ + "${uki_temp_dir}" \ + "${cert_dir}" \ + "${usr_hash}"; then + die "UKI/RPM: failed to prepare /usr root-hash signature companion" + fi + fi + + # Bind the policy credential to the signed UKI command line. + local ipe_policy_cred="" + local ipe_policy_hash_token="" + if [[ "${IPE_CAPABLE}" == "true" ]]; then + local staged_cred + staged_cred="$(readlink -f "$(dirname "${FLAGS_disk_image}")")/acl-ipe-policy/acl-ipe-policy.p7b.cred" + [[ -s "${staged_cred}" ]] || + die "UKI/RPM: staged IPE policy candidate not found at ${staged_cred}" + local policy_sha256 + policy_sha256="$(sha256sum "${staged_cred}" | cut -d' ' -f1)" + policy_sha256="${policy_sha256,,}" + [[ "${policy_sha256}" =~ ^[0-9a-f]{64}$ ]] || + die "UKI/RPM: invalid SHA-256 of staged IPE policy" + ipe_policy_cred="${staged_cred}" + ipe_policy_hash_token="acl.ipe.policy_sha256=${policy_sha256}" + info "UKI/RPM: IPE policy SHA-256 = ${policy_sha256}" + fi + + local cmdline="" + if [[ ${FLAGS_verity} -eq ${FLAGS_TRUE} ]]; then + local verity_usr_options="hash-offset=${verity_hash_offset},panic-on-corruption" + if [[ -n "${IPE_VERITY_SIG_PATH}" ]]; then + verity_usr_options+=",root-hash-signature=${IPE_VERITY_SIG_PATH}" + fi cmdline="mount.usr=/dev/mapper/usr mount.usrflags=ro" cmdline+=" systemd.verity_usr_data=PARTUUID=${usr_a_uuid}" cmdline+=" systemd.verity_usr_hash=PARTUUID=${usr_a_uuid}" - cmdline+=" systemd.verity_usr_options=hash-offset=${verity_hash_offset},panic-on-corruption" + cmdline+=" systemd.verity_usr_options=${verity_usr_options}" cmdline+=" usrhash=${usr_hash}" else cmdline="mount.usr=PARTUUID=${usr_a_uuid} mount.usrflags=ro" @@ -171,6 +266,9 @@ OSREL # Common base args — platform-agnostic, same for all image types. cmdline+=" root=LABEL=ROOT rootflags=rw" cmdline+=" consoleblank=0" + if [[ -n "${ipe_policy_hash_token}" ]]; then + cmdline+=" ${ipe_policy_hash_token}" + fi # NOTE: crashkernel=256M is delivered via a UKI addon (kdump.addon.efi) # rather than baked into the main UKI cmdline. This allows disabling # kdump by removing the addon from the ESP without rebuilding the UKI. @@ -278,6 +376,22 @@ OSREL sudo cp "${uki_output}" "${ESP_DIR}/EFI/Linux/${uki_name}" info "UKI/RPM: Installed UKI → EFI/Linux/${uki_name}" + if [[ -n "${IPE_VERITY_SIG_SOURCE}" ]]; then + local signature_dir="${ESP_DIR}/EFI/Linux/${uki_name}.extra.d" + sudo mkdir -p "${signature_dir}" + sudo rm -f "${signature_dir}"/verity-usr-*.p7s.cred + sudo cp "${IPE_VERITY_SIG_SOURCE}" "${signature_dir}/" + info "UKI/RPM: Installed /usr root-hash signature companion → EFI/Linux/${uki_name}.extra.d/$(basename "${IPE_VERITY_SIG_SOURCE}")" + fi + + local cred_dir="${ESP_DIR}/EFI/Linux/${uki_name}.extra.d" + sudo rm -f "${cred_dir}"/acl-ipe-policy*.cred + if [[ -n "${ipe_policy_cred}" ]]; then + sudo mkdir -p "${cred_dir}" + sudo cp "${ipe_policy_cred}" "${cred_dir}/acl-ipe-policy.p7b.cred" + info "UKI/RPM: Installed IPE policy credential → EFI/Linux/${uki_name}.extra.d/acl-ipe-policy.p7b.cred" + fi + sudo mkdir -p "${ESP_DIR}/loader" sudo tee "${ESP_DIR}/loader/loader.conf" > /dev/null <<-EOF timeout 0 diff --git a/ci-automation/vendor-testing/azure.sh b/ci-automation/vendor-testing/azure.sh index e57d47a04e9..b5c0ded8fe4 100755 --- a/ci-automation/vendor-testing/azure.sh +++ b/ci-automation/vendor-testing/azure.sh @@ -71,6 +71,33 @@ run_kola_tests() { image_arg="--azure-image-file=${AZURE_IMAGE_NAME}" fi + local trusted_launch_args=() + if [[ "${AZURE_TRUSTED_LAUNCH:-}" == "true" ]]; then + trusted_launch_args+=(--azure-trusted-launch --enable-secureboot) + fi + + local secure_boot_certificate_args=() + if [[ -n "${AZURE_SECURE_BOOT_CERTIFICATES:-}" ]]; then + if [[ "${AZURE_TRUSTED_LAUNCH:-}" != "true" ]]; then + echo "AZURE_SECURE_BOOT_CERTIFICATES requires AZURE_TRUSTED_LAUNCH=true" >&2 + return 1 + fi + if [[ -n "${AZURE_DISK_URI:-}" ]]; then + echo "AZURE_SECURE_BOOT_CERTIFICATES cannot modify an existing gallery image version" >&2 + return 1 + fi + local certificate + local certificates=() + IFS=':' read -r -a certificates <<< "${AZURE_SECURE_BOOT_CERTIFICATES}" + for certificate in "${certificates[@]}"; do + if [[ -z "${certificate}" ]]; then + echo "AZURE_SECURE_BOOT_CERTIFICATES contains an empty path" >&2 + return 1 + fi + secure_boot_certificate_args+=(--azure-secureboot-certificate="${certificate}") + done + fi + timeout --signal=SIGQUIT 6h \ kola run \ ${debug_flag} \ @@ -86,6 +113,8 @@ run_kola_tests() { --azure-size="${instance_type}" \ --azure-sku="${sku}" \ --azure-hyper-v-generation="${hyperv_gen}" \ + "${trusted_launch_args[@]}" \ + "${secure_boot_certificate_args[@]}" \ ${AZURE_USE_GALLERY} \ ${AZURE_KOLA_VNET:+--azure-kola-vnet=${AZURE_KOLA_VNET}} \ ${azure_vnet_subnet_name:+--azure-vnet-subnet-name=${azure_vnet_subnet_name}} \ diff --git a/image_to_vm.sh b/image_to_vm.sh index 0af6641b8cc..496d2c69ea0 100755 --- a/image_to_vm.sh +++ b/image_to_vm.sh @@ -142,9 +142,16 @@ run_fs_hook # output directory so _write_qemu_uefi_secure_conf() can enroll it in the OVMF # Secure Boot db. if [[ "${PACKAGE_SOURCE_MODE}" == "RPM" && "${BOOTLOADER_MODE:-uki}" == "uki" ]]; then + ephemeral_cert_dir="${FLAGS_from}/acl-ipe-ephemeral" + # Test and production VHDs are converted separately. Keep one per-build + # certificate so both images match the single certificate exported for + # QEMU and Azure gallery enrollment, including when IPE assets are disabled. + "${BUILD_LIBRARY_DIR}/rpm/ensure_ephemeral_cert.sh" "${ephemeral_cert_dir}" || + die_notrace "Failed to prepare the per-build Secure Boot certificate" "${BUILD_LIBRARY_DIR}/rpm/sign_uki_ephemeral.sh" \ "${VM_TMP_ROOT}/boot" \ - "$(_dst_dir)" + "$(_dst_dir)" \ + "${ephemeral_cert_dir}" fi # Changes done, glue it together diff --git a/run_azure_tests.sh b/run_azure_tests.sh index 5b33160ecc0..a2a5af6a709 100755 --- a/run_azure_tests.sh +++ b/run_azure_tests.sh @@ -100,6 +100,8 @@ ${AZURE_amd64_MACHINE_SIZE:+export AZURE_amd64_MACHINE_SIZE="${AZURE_amd64_MACHI ${AZURE_arm64_MACHINE_SIZE:+export AZURE_arm64_MACHINE_SIZE="${AZURE_arm64_MACHINE_SIZE}"} ${AZURE_KOLA_VNET:+export AZURE_KOLA_VNET="${AZURE_KOLA_VNET}"} ${AZURE_USE_GALLERY:+export AZURE_USE_GALLERY="${AZURE_USE_GALLERY}"} +${AZURE_TRUSTED_LAUNCH:+export AZURE_TRUSTED_LAUNCH="${AZURE_TRUSTED_LAUNCH}"} +${AZURE_SECURE_BOOT_CERTIFICATES:+export AZURE_SECURE_BOOT_CERTIFICATES=${AZURE_SECURE_BOOT_CERTIFICATES@Q}} ${AZURE_RESOURCE_GROUP_TAG:+export AZURE_RESOURCE_GROUP_TAG="${AZURE_RESOURCE_GROUP_TAG}"} ${AZURE_DISK_URI:+export AZURE_DISK_URI=${AZURE_DISK_URI@Q}} ${KOLA_TRUSTED_SOURCE_CIDR:+export KOLA_TRUSTED_SOURCE_CIDR=${KOLA_TRUSTED_SOURCE_CIDR@Q}} diff --git a/run_sdk_container b/run_sdk_container index 26d0d403aed..09898e20d0a 100755 --- a/run_sdk_container +++ b/run_sdk_container @@ -79,6 +79,28 @@ while [[ $# -gt 0 ]] ; do esac done +ACL_IPE_MODE="${ACL_IPE_MODE:-disabled}" +case "${ACL_IPE_MODE}" in + disabled|audit) ;; + enforcing) + echo "IPE mode 'enforcing' is reserved and not supported by this build" >&2 + exit 1 + ;; + *) + echo "Invalid ACL_IPE_MODE: ${ACL_IPE_MODE}" >&2 + exit 1 + ;; +esac + +ACL_IPE_SIGNING_MODE="${ACL_IPE_SIGNING_MODE:-ephemeral}" +case "${ACL_IPE_SIGNING_MODE}" in + ephemeral|esrp) ;; + *) + echo "Invalid ACL_IPE_SIGNING_MODE: ${ACL_IPE_SIGNING_MODE}" >&2 + exit 1 + ;; +esac + if [[ -n ${custom_image} ]] ; then container_image_name=${custom_image} else @@ -184,4 +206,8 @@ fi # Workaround: The SDK expects to be able to write to /etc/hosts call_docker exec "${name}" sh -c 'cp /etc/hosts /etc/hosts2; umount /etc/hosts ; mv /etc/hosts2 /etc/hosts' -call_docker exec "${tty[@]}" -i -e INJECT_DOCKER_SYSEXT="${INJECT_DOCKER_SYSEXT:-false}" "${name}" /mnt/host/source/src/scripts/sdk_lib/sdk_entry.sh "$@" +call_docker exec "${tty[@]}" -i \ + -e INJECT_DOCKER_SYSEXT="${INJECT_DOCKER_SYSEXT:-false}" \ + -e ACL_IPE_MODE="${ACL_IPE_MODE}" \ + -e ACL_IPE_SIGNING_MODE="${ACL_IPE_SIGNING_MODE}" \ + "${name}" /mnt/host/source/src/scripts/sdk_lib/sdk_entry.sh "$@" diff --git a/sdk_lib/sdk_container_common.sh b/sdk_lib/sdk_container_common.sh index 09454b0a906..1c39031b8e9 100644 --- a/sdk_lib/sdk_container_common.sh +++ b/sdk_lib/sdk_container_common.sh @@ -66,7 +66,7 @@ function get_git_version() { if [ -z "$tag" ] ; then # 'git describe' always prints a single line; leave it unpiped so its # exit status still propagates to the caller. - git describe --tags + git describe --tags 2>/dev/null || get_version_from_versionfile else echo "$tag" fi diff --git a/sdk_lib/sdk_entry.sh b/sdk_lib/sdk_entry.sh index 08339bb52d3..68f125fc4cd 100755 --- a/sdk_lib/sdk_entry.sh +++ b/sdk_lib/sdk_entry.sh @@ -108,6 +108,40 @@ fi sed -i -e '/export INJECT_DOCKER_SYSEXT=/d' /home/sdk/.bashrc 2>/dev/null || true echo "export INJECT_DOCKER_SYSEXT='${INJECT_DOCKER_SYSEXT:-false}'" >> /home/sdk/.bashrc +ACL_IPE_MODE="${ACL_IPE_MODE:-disabled}" +case "${ACL_IPE_MODE}" in + disabled) + ACL_IPE_CAPABLE=false + ;; + audit) + ACL_IPE_CAPABLE=true + ;; + enforcing) + echo "ERROR: ACL_IPE_MODE=enforcing is reserved and not supported by this build" >&2 + exit 1 + ;; + *) + echo "ERROR: ACL_IPE_MODE must be disabled, audit, or enforcing (got: ${ACL_IPE_MODE})" >&2 + exit 1 + ;; +esac +ACL_IPE_SIGNING_MODE="${ACL_IPE_SIGNING_MODE:-ephemeral}" +case "${ACL_IPE_SIGNING_MODE}" in + ephemeral|esrp) ;; + *) + echo "ERROR: ACL_IPE_SIGNING_MODE must be ephemeral or esrp (got: ${ACL_IPE_SIGNING_MODE})" >&2 + exit 1 + ;; +esac +sed -i \ + -e '/export ACL_IPE_MODE=/d' \ + -e '/export ACL_IPE_SIGNING_MODE=/d' \ + -e '/export ACL_IPE_CAPABLE=/d' \ + /home/sdk/.bashrc 2>/dev/null || true +echo "export ACL_IPE_MODE='${ACL_IPE_MODE}'" >> /home/sdk/.bashrc +echo "export ACL_IPE_SIGNING_MODE='${ACL_IPE_SIGNING_MODE}'" >> /home/sdk/.bashrc +echo "export ACL_IPE_CAPABLE='${ACL_IPE_CAPABLE}'" >> /home/sdk/.bashrc + if [ $# -gt 0 ] ; then cmd="/home/sdk/.cmd" echo -n "exec bash -l -i -c '" >"$cmd"