diff --git a/acl/build_rpm_image.sh b/acl/build_rpm_image.sh index 1441be08697..a1e72a50290 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) @@ -116,6 +119,7 @@ fi # shellcheck source=../build_library/retry_with_backoff.sh source "${SCRIPT_DIR}/build_library/retry_with_backoff.sh" source "${SCRIPT_DIR}/build_library/standalone_sysext_util.sh" +source "${SCRIPT_DIR}/build_library/rpm/ipe_artifact.sh" cd "${SCRIPT_DIR}" # Default configuration @@ -171,6 +175,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 +248,141 @@ show_help() { exit 0 } +validate_ipe_boot_path() { + local vm_type="${1:-}" + local -a az_vm_args=() + + [[ "${ACL_IPE_CAPABLE}" == "false" ]] && return 0 + if [[ "${BOOTLOADER_MODE}" != "uki" ]]; then + error "IPE requires BOOTLOADER_MODE=uki" + return 1 + fi + if [[ "${vm_type}" == "qemu" ]]; then + error "IPE runtime mode testing requires the Azure IMDS UKI path" + return 1 + fi + if [[ "${vm_type}" == "azure" ]]; then + ipe_split_argument_string az_vm_args "${AZ_VM_ARGS:-}" + if ! ipe_reject_azure_image_overrides "${az_vm_args[@]}"; then + error "--az-vm-args cannot override the image for IPE-capable Azure images" + return 1 + fi + fi + return 0 +} + +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 artifact_signing_mode + local expected_capability="" + + if [[ "${IPE_MODE_OVERRIDE_SET}" == "true" ]]; then + [[ "${ACL_IPE_MODE}" == "audit" ]] && expected_capability=true || + expected_capability=false + fi + artifact_signing_mode="$( + ipe_resolve_artifact_signing_mode "${artifact_dir}" "${expected_capability}" + )" || return 1 + + if [[ "${artifact_signing_mode}" == "disabled" ]]; then + ACL_IPE_MODE=disabled + configure_ipe_mode + return 0 + fi + + if [[ "${IPE_MODE_OVERRIDE_SET}" != "true" ]]; then + ACL_IPE_MODE=audit + 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() { + if [[ "$REUSE_VM" == "true" ]] && + [[ "$BUILD_VM_IMAGE" != "true" ]] && + [[ "$BUILD_TEST_IMAGE" != "true" ]]; then + return 1 + fi + + [[ "$BUILD_IMAGE" != "true" ]] && + ! operation_uses_gallery_image && + operation_uses_vm_image +} + +load_reused_vm_type() { + local state_file="${1:-${SCRIPT_DIR}/.vm-state.env}" + local key value state_vm_type="" + + [[ "${REUSE_VM}" == "true" ]] || return 0 + if [[ ! -r "${state_file}" ]]; then + error "--reuse-vm requires VM state at ${state_file}" + return 1 + fi + + while IFS='=' read -r key value; do + if [[ "${key}" == "VM_TYPE" ]]; then + state_vm_type="${value}" + break + fi + done < "${state_file}" + + case "${state_vm_type}" in + azure|qemu) VM_TYPE="${state_vm_type}" ;; + *) + error "Invalid or missing VM_TYPE in ${state_file}" + return 1 + ;; + esac +} + # Parse command line arguments parse_args() { while [[ $# -gt 0 ]]; do @@ -251,6 +403,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 +773,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 +789,21 @@ parse_args() { fi fi + load_reused_vm_type || exit 1 + + 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,9 +827,17 @@ 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 + if [[ "${ACL_IPE_CAPABLE}" == "true" ]]; then + RUN_HOST_SCRIPTS+=("./acl/tests/ipe/run-ipe-mode-toggle-test.sh") + fi RUN_HOST_SCRIPTS+=("./acl/tests/run-selinux-toggle-test.sh") fi @@ -870,6 +1069,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 +1254,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 +1464,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..3ab2a3c3c0b 100644 --- a/acl/docs/BUILD_RPM_IMAGE_README.md +++ b/acl/docs/BUILD_RPM_IMAGE_README.md @@ -155,6 +155,27 @@ Build the Flatcar production image using RPM package sources. ./acl/build_rpm_image.sh --rebuild ``` +**Optional IPE audit (UKI images)** + +Use `ephemeral` for dev/test or `esrp` for later production signing by +`ACL-Publish-MCR-And-ACG`; both initially build a test-signed policy. + +```bash +./acl/build_rpm_image.sh --rebuild --ipe-mode=audit --ipe-signing-mode=ephemeral +./acl/build_rpm_image.sh --rebuild --ipe-mode=audit --ipe-signing-mode=esrp +``` + +IPE defaults to disabled. On Azure, set the VM tag +`acl-node-security-profile=ipe=audit` to log would-deny events without blocking +execution. Policy-load failures are logged and leave IPE inactive without +blocking boot. `enforcing` is unsupported; QEMU runtime testing requires +`--ipe-mode=disabled`. See [architecture](architecture.md) for policy trust, +credential handling and dm-verity details. +The policy hash is reserved for the main UKI command line; IPE image +conversion rejects active UKI addons (including the optional +`EXTRA_KERNEL_CMDLINE` debug addon) that supply another +`acl.ipe.policy_sha256` token. + **Build output location:** `__build__/images/images/amd64-usr/latest/` ### Phase 4: Build VM Image (Optional) @@ -322,6 +343,21 @@ By default, before starting a new Azure VM, all the pre-existing resource groups ./acl/build_rpm_image.sh --start-vm --vm-type=azure --no-cleanup ``` +For test-signed IPE policies, including unpublished ESRP candidates, use the +Secure Boot smoke path with the matching `uki-signing-ca.pem`. Kernel-trusted +production policies can activate on Standard Gen2, but IPE does not select +that VM profile automatically. `--run-tests` still includes the Secure Boot check. + +IPE-capable launches reject image overrides and `--reuse-image` before VM cleanup. +The latest gallery version has no verified IPE capability/signing contract, so +use a local IPE VHD or explicitly select a gallery version whose IPE +capability and signing provenance you have verified separately. +For local IPE VHDs, the launcher pins the resolved build directory before +preflight and uploads from that same directory even if `latest` moves. Keep +build-version directories unchanged during upload; pinning cannot prevent +in-place modification of a VHD. +Non-IPE `--reuse-image` behavior is unchanged. + You can also use the `--run-script` flag to run tests on the Azure VM, just like with the QEMU VM. #### Access the VM @@ -483,6 +519,9 @@ AZURE_TOKEN_CREDENTIALS=AzureCLICredential \ ./run_azure_tests.sh amd64 2 cl.ignition.v1.once coreos.ignition.once ``` +Kola boot success alone does not prove active IPE audit. Use the IPE smoke +test to verify policy activation and execution-correlated audit events. + **Arguments:** - `amd64` or `arm64` — target architecture diff --git a/acl/docs/architecture.md b/acl/docs/architecture.md index 6339e9e7f70..5b04dd1e965 100644 --- a/acl/docs/architecture.md +++ b/acl/docs/architecture.md @@ -56,6 +56,10 @@ The `/usr` partition (USR-A) is a read-only btrfs filesystem with zstd compressi - The verity hash tree is stored in a dedicated hash partition (HASH-A, immediately following USR-A on disk). - At boot, `systemd-veritysetup` activates the verity device using slot-specific parameters delivered via a per-slot systemd-stub addon: `systemd.verity_usr_data=PARTUUID=`, `systemd.verity_usr_hash=PARTUUID=`, and `systemd.verity_usr_options=panic-on-corruption`. - The main UKI cmdline stays slot-independent (`mount.usr=/dev/mapper/usr`); Trident switches slots by swapping which addon is active in `.efi.extra.d/`, so the same signed UKI boots either A or B. (The secondary GRUB boot path is out of scope for A/B update and is unchanged: it still uses the inline PARTUUID + hash-offset verity cmdline on the existing non-UKI partition layout.) +- The active slot addon carries `usrhash` without a detached root-hash signature or `root-hash-signature=` option. dm-verity still checks block integrity; `/usr` does not satisfy IPE's `dmverity_signature=TRUE` allow rule, so execution produces would-deny events when the policy is active in audit mode. +- 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`. Azure launchers retain their existing defaults rather than selecting a VM security profile from IPE capability. Production policies trusted by the kernel can activate without Trusted Launch; test-signed policies still require their test trust. +- 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`, alongside the active slot addon. The main UKI cmdline includes an `acl.ipe.policy_sha256=` token that binds the credential to the signed kernel command line independently of slot selection. 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` (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; the `ACL-Publish-MCR-And-ACG` pipeline 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. +- Before VM conversion re-signs an IPE UKI, it verifies the staged credential's signer, selects the UKI named by `loader.conf`, and requires that UKI's exact companion credential to match the staged bytes and its single `acl.ipe.policy_sha256` token. Inconsistent artifacts fail conversion rather than being discovered only at boot. - 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..85db828751b --- /dev/null +++ b/acl/tests/azure-security-profile-test-common.sh @@ -0,0 +1,220 @@ +#!/bin/bash +# Shared host-side IPE/SELinux helpers for Azure profile changes and reboots. +# Provide exit cleanup that restores the original profile after test mutations. + +source "${SCRIPT_DIR}/build_library/rpm/additional_files/acl-node-security-profile.sh" + +SSH_OPTS=() +SECURITY_PROFILE_TAG_NAME="acl-node-security-profile" +ORIGINAL_SECURITY_PROFILE_STATE="" +SECURITY_PROFILE_MUTATED=false + +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_state() { + 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 -sce --arg tag "${SECURITY_PROFILE_TAG_NAME}" \ + "$(acl_security_profile_jq_filter)" <<<"${raw}" +} + +imds_security_profile() { + local state + state="$(imds_security_profile_state)" || return 1 + jq -r 'if .present then .value else "" end' <<< "${state}" +} + +azure_security_profile_state() { + az vm show \ + --resource-group "${VM_RG}" \ + --name "${VM_NAME}" \ + --query tags \ + --output json | + jq -ce --arg tag "${SECURITY_PROFILE_TAG_NAME}" ' + if type == "object" and has($tag) then + if (.[$tag] | type) != "string" then + error("security profile tag value is not a string") + else + {present: true, value: .[$tag]} + end + else + {present: false, value: ""} + end + ' +} + +security_profile_with_key() { + local profile="$1" target_key="$2" target_value="$3" + local segment segment_key updated="" + local -a segments=() + + IFS=',' read -r -a segments <<< "${profile}" + for segment in "${segments[@]}"; do + [[ -n "${segment}" ]] || continue + segment_key="${segment%%=*}" + [[ "${segment_key}" == "${target_key}" ]] && continue + updated+="${updated:+,}${segment}" + done + updated+="${updated:+,}${target_key}=${target_value}" + printf '%s\n' "${updated}" +} + +boot_id() { + ssh_cmd 'cat /proc/sys/kernel/random/boot_id' 2>/dev/null +} + +set_security_profile_tag_state() { + local state="$1" + local present value 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)" || { + error "Could not resolve the Azure VM resource ID" + return 1 + } + [[ -n "${vm_id}" ]] || { + error "Azure VM resource ID is empty" + return 1 + } + if ! jq -e ' + type == "object" and + (.present | type) == "boolean" and + (.value | type) == "string" + ' >/dev/null <<< "${state}"; then + error "Invalid security profile tag state" + return 1 + fi + present="$(jq -r '.present' <<< "${state}")" + value="$(jq -r '.value' <<< "${state}")" + SECURITY_PROFILE_MUTATED=true + if [[ "${present}" == "false" ]]; then + info "Removing acl-node-security-profile tag..." + if ! az tag update \ + --resource-id "$vm_id" \ + --operation delete \ + --tags "${SECURITY_PROFILE_TAG_NAME}=" \ + --output none; then + error "Could not remove acl-node-security-profile" + return 1 + fi + else + info "Setting acl-node-security-profile=${value}..." + if ! az tag update \ + --resource-id "$vm_id" \ + --operation merge \ + --tags "${SECURITY_PROFILE_TAG_NAME}=${value}" \ + --output none; then + error "Could not update acl-node-security-profile" + return 1 + fi + fi + + info "Waiting for in-guest IMDS to report the updated security profile..." + local deadline seen_state + deadline=$(( $(date +%s) + 60 )) + while (( $(date +%s) < deadline )); do + seen_state="$(imds_security_profile_state)" && + [[ "${seen_state}" == "${state}" ]] && + return 0 + sleep 2 + done + error "IMDS did not converge to the requested security profile within 60s" + return 1 +} + +set_security_profile_key() { + local key="$1" value="$2" + local current_profile updated_profile desired_state + + current_profile="$(imds_security_profile)" || { + error "Could not read current acl-node-security-profile from IMDS" + return 1 + } + updated_profile="$(security_profile_with_key "${current_profile}" "${key}" "${value}")" + desired_state="$(jq -cn --arg value "${updated_profile}" '{present: true, value: $value}')" + set_security_profile_tag_state "${desired_state}" +} + +capture_security_profile_state() { + ORIGINAL_SECURITY_PROFILE_STATE="$(azure_security_profile_state)" || { + error "Could not capture the original acl-node-security-profile tag" + return 1 + } + info "Captured the original acl-node-security-profile tag" +} + +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 prefix + diag_dir="${DIAGNOSTICS_DIR:-/tmp}" + mkdir -p "$diag_dir" + 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_key_and_reboot() { + set_security_profile_key "$1" "$2" + reboot_and_wait +} + +restore_security_profile_on_exit() { + local primary_status=$? + local final_status="${primary_status}" + + trap - EXIT + if [[ "${SECURITY_PROFILE_MUTATED}" == "true" ]] && + [[ -n "${ORIGINAL_SECURITY_PROFILE_STATE}" ]]; then + section "Restoring original acl-node-security-profile tag" + if ! set_security_profile_tag_state "${ORIGINAL_SECURITY_PROFILE_STATE}" || + ! reboot_and_wait; then + warn "Failed to restore the original security profile and reboot" + [[ "${final_status}" -ne 0 ]] || final_status=1 + else + info "Restored the original security profile" + fi + fi + + exit "${final_status}" +} diff --git a/acl/tests/ipe/offline/function-extraction.sh b/acl/tests/ipe/offline/function-extraction.sh new file mode 100644 index 00000000000..44cb2a3a9b7 --- /dev/null +++ b/acl/tests/ipe/offline/function-extraction.sh @@ -0,0 +1,50 @@ +#!/bin/bash + +# Load functions with name() { headers without executing top-level script code. +# Only whitespace or a comment may follow the closing brace. +source_test_functions() { + if [[ $# -lt 2 || ! -f "$1" ]]; then + echo "Expected a script and at least one function name" >&2 + return 1 + fi + + local file="$1" name definition line diagnostics complete + shift + + for name; do + if [[ ! "${name}" =~ ^[a-zA-Z_][a-zA-Z_0-9]*$ ]]; then + echo "Invalid function name: ${name}" >&2 + return 1 + fi + definition="" + complete=false + while IFS= read -r line || [[ -n "${line}" ]]; do + if [[ -z "${definition}" && "${line}" != "${name}() {" ]]; then + continue + fi + + # Let Bash distinguish closing braces from quoted text and heredocs. + while [[ "${line}" == *'}'* ]]; do + definition+="${line%%\}*}}" + line="${line#*\}}" + if diagnostics="$(bash -n <<< "${definition}" 2>&1)"; then + if [[ -n "${diagnostics}" || + ! "${line}" =~ ^([[:space:]]+#.*|[[:space:]]*)$ ]]; then + echo "Unsupported trailing syntax in function ${name} from ${file}" >&2 + return 1 + fi + complete=true + break + fi + done + [[ "${complete}" == true ]] && break + definition+="${line}"$'\n' + done < "${file}" + + if [[ "${complete}" != true ]]; then + echo "Could not extract complete function ${name} from ${file}" >&2 + return 1 + fi + eval "${definition}" + done +} 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-azure-vendor-ipe-contract.sh b/acl/tests/ipe/offline/test-azure-vendor-ipe-contract.sh new file mode 100755 index 00000000000..2baef973e87 --- /dev/null +++ b/acl/tests/ipe/offline/test-azure-vendor-ipe-contract.sh @@ -0,0 +1,156 @@ +#!/bin/bash +# shellcheck disable=SC1091,SC2016,SC2034 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)" +TEST_DIR="$(mktemp -d)" +trap 'rm -rf "${TEST_DIR}"' EXIT + +MOCK_BIN="${TEST_DIR}/bin" +mkdir -p "${MOCK_BIN}" + +cat > "${MOCK_BIN}/timeout" <<'EOF' +#!/bin/bash +set -euo pipefail +while [[ "${1:-}" == --* ]]; do + shift +done +shift +exec "$@" +EOF + +cat > "${MOCK_BIN}/kola" <<'EOF' +#!/bin/bash +set -euo pipefail +printf '%s\n' "$@" > "${KOLA_ARGS_LOG:?}" +if [[ "${1:-}" == "list" ]]; then + printf '%s\n' cl.basic +fi +EOF + +cat > "${MOCK_BIN}/curl" <<'EOF' +#!/bin/bash +echo "Unexpected network request: $*" >&2 +exit 1 +EOF +chmod +x "${MOCK_BIN}/timeout" "${MOCK_BIN}/kola" "${MOCK_BIN}/curl" + +assert_single_arg() { + local expected="$1" file="$2" + [[ "$(grep -Fxc -- "${expected}" "${file}")" -eq 1 ]] || { + echo "Expected exactly one ${expected} in ${file}" >&2 + return 1 + } +} + +run_vendor() { + local main_dir="$1" arch="$2" + shift 2 + ( + cd "${SCRIPT_DIR}" + PATH="${MOCK_BIN}:${PATH}" \ + PACKAGE_SOURCE_MODE=RPM \ + AZURE_amd64_MACHINE_SIZE=Standard_D2s_v5 \ + AZURE_arm64_MACHINE_SIZE=Standard_D2ps_v5 \ + AZURE_LOCATION=westus2 \ + AZURE_PARALLEL=1 \ + bash ci-automation/vendor-testing/azure.sh \ + "${main_dir}" "${main_dir}/work" "${arch}" 1.0.0 results.tap "$@" + ) > "${main_dir}/vendor.log" 2>&1 || { + cat "${main_dir}/vendor.log" >&2 + return 1 + } +} + +test_ipe_does_not_select_kola_security() ( + local arch="$1" source="$2" + local root="${TEST_DIR}/${arch}-${source}" mode size=Standard_D2s_v5 + [[ "${arch}" != arm64 ]] || size=Standard_D2ps_v5 + mkdir -p "${root}/work" "${root}/artifacts" + printf 'test-version\n' > "${root}/git_version" + printf 'developer\n' > "${root}/git_channel" + touch "${root}/first_run" + export KOLA_ARGS_LOG="${root}/kola.args" + export AZURE_IMAGE_NAME="${root}/artifacts/test.vhd" + export AZURE_USE_GALLERY="" + export AZURE_DISK_URI="" + export AZURE_TRUSTED_LAUNCH=true + export AZURE_SECURE_BOOT_CERTIFICATES="${root}/artifacts/uki-signing-ca.pem" + if [[ "${source}" == gallery ]]; then + AZURE_DISK_URI="/subscriptions/example/imageVersions/1" + else + printf 'vhd\n' > "${AZURE_IMAGE_NAME}" + fi + + for mode in disabled ephemeral esrp; do + export ACL_IPE_CAPABLE=false + export ACL_IPE_SIGNING_MODE="${mode}" + if [[ "${mode}" != disabled ]]; then + ACL_IPE_CAPABLE=true + printf '%s\n' "${mode}" > "${root}/artifacts/ipe-signing-mode" + fi + run_vendor "${root}" "${arch}" cl.basic >/dev/null + assert_single_arg "--board=${arch}-usr" "${KOLA_ARGS_LOG}" + assert_single_arg "--azure-size=${size}" "${KOLA_ARGS_LOG}" + assert_single_arg "--azure-hyper-v-generation=V2" "${KOLA_ARGS_LOG}" + if [[ "${source}" == gallery ]]; then + assert_single_arg "--azure-disk-uri=${AZURE_DISK_URI}" "${KOLA_ARGS_LOG}" + if grep -Fq -- '--azure-use-gallery' "${KOLA_ARGS_LOG}"; then + echo "Existing gallery launch unexpectedly requested image creation" >&2 + return 1 + fi + else + assert_single_arg "--azure-image-file=${AZURE_IMAGE_NAME}" "${KOLA_ARGS_LOG}" + assert_single_arg "--azure-use-gallery" "${KOLA_ARGS_LOG}" + fi + if grep -Eq -- '^--(azure-trusted-launch|enable-secureboot|azure-secureboot-certificate)' "${KOLA_ARGS_LOG}"; then + echo "IPE unexpectedly changed ${arch} ${source} launch security" >&2 + return 1 + fi + if [[ "${mode}" == disabled ]]; then + cp "${KOLA_ARGS_LOG}" "${root}/baseline.args" + else + cmp "${root}/baseline.args" "${KOLA_ARGS_LOG}" + fi + done + + AZURE_USE_GALLERY="--azure-trusted-launch --enable-secureboot" \ + run_vendor "${root}" "${arch}" --azure-use-gallery=false >/dev/null + assert_single_arg --azure-trusted-launch "${KOLA_ARGS_LOG}" + assert_single_arg --enable-secureboot "${KOLA_ARGS_LOG}" + assert_single_arg --azure-use-gallery=false "${KOLA_ARGS_LOG}" +) + +test_wrapper_does_not_forward_ipe_security() ( + source "${SCRIPT_DIR}/run_azure_tests.sh" + local root="${TEST_DIR}/wrapper" + mkdir -p "${root}/sdk_container" "${root}/home/.azure" + cd "${root}" + export HOME="${root}/home" + export AZURE_SUBSCRIPTION_ID=00000000-0000-0000-0000-000000000000 + export AZURE_TRUSTED_LAUNCH=true + export AZURE_SECURE_BOOT_CERTIFICATES=/work/uki-signing-ca.pem + export ACL_IPE_CAPABLE=true + export ACL_IPE_SIGNING_MODE=esrp + export PACKAGE_SOURCE_MODE=RPM + export AZURE_USE_GALLERY=--azure-use-gallery + az() { printf 'test-tenant\n'; } + set_azure_vars arm64 1 + if grep -Eq 'AZURE_TRUSTED_LAUNCH|AZURE_SECURE_BOOT_CERTIFICATES|ACL_IPE_' sdk_container/.env; then + echo "Wrapper unexpectedly forwarded IPE launch settings" >&2 + return 1 + fi + unset AZURE_USE_GALLERY + source sdk_container/.env + [[ "${AZURE_USE_GALLERY}" == --azure-use-gallery ]] + [[ "${AZURE_IMAGE_NAME}" == /work/__build__/images/images/arm64-usr/latest/acl_production_azure_test_image.vhd ]] +) + +for arch in amd64 arm64; do + for source in local gallery; do + test_ipe_does_not_select_kola_security "${arch}" "${source}" + done +done +test_wrapper_does_not_forward_ipe_security +echo "Azure vendor baseline launch contract tests passed" diff --git a/acl/tests/ipe/offline/test-function-extraction-contract.sh b/acl/tests/ipe/offline/test-function-extraction-contract.sh new file mode 100755 index 00000000000..53d951fa422 --- /dev/null +++ b/acl/tests/ipe/offline/test-function-extraction-contract.sh @@ -0,0 +1,112 @@ +#!/bin/bash + +set -euo pipefail + +TEST_DIR="$(mktemp -d)" +trap 'rm -rf "${TEST_DIR}"' EXIT +# shellcheck disable=SC1091 # Resolve the helper beside this test at runtime. +source "$(dirname "${BASH_SOURCE[0]}")/function-extraction.sh" + +cat > "${TEST_DIR}/entrypoint.sh" <<'EOF' +example() { + printf 'loaded\n' +} +printf 'executed\n' > "${TEST_DIR}/main-ran" +EOF + +source_test_functions "${TEST_DIR}/entrypoint.sh" example +[[ "$(example)" == "loaded" ]] +[[ ! -e "${TEST_DIR}/main-ran" ]] + +assert_extraction_fails() { + if source_test_functions "$@" >/dev/null 2>&1; then + printf 'Invalid function extraction succeeded: %s\n' "$*" >&2 + exit 1 + fi +} + +assert_extraction_fails +assert_extraction_fails "${TEST_DIR}/entrypoint.sh" +assert_extraction_fails "${TEST_DIR}/entrypoint.sh" 'example; echo injected' +assert_extraction_fails "${TEST_DIR}/entrypoint.sh" missing + +cat > "${TEST_DIR}/incomplete.sh" <<'EOF' +incomplete() { + printf 'missing closing brace\n' +EOF +assert_extraction_fails "${TEST_DIR}/incomplete.sh" incomplete + +cat > "${TEST_DIR}/indented.sh" <<'EOF' +indented() { + printf 'indented\n' + } +printf 'executed\n' > "${TEST_DIR}/main-ran" +EOF +source_test_functions "${TEST_DIR}/indented.sh" indented +[[ "$(indented)" == indented ]] +[[ ! -e "${TEST_DIR}/main-ran" ]] + +cat > "${TEST_DIR}/formatting.sh" <<'EOF' +printf 'executed\n' > "${TEST_DIR}/main-ran" +nested() { + { + printf 'nested\n' +} + printf 'tail\n' +} # A closing brace may have a comment. +heredoc() { + cat <<'JSON' +{ + "value": "}" +} +JSON +} +quoted() { + printf '%s\n' "${1:-}}" '{' \ + 'quoted +} +text' + # A comment containing } must not close the function. +} +printf 'executed\n' > "${TEST_DIR}/main-ran" +EOF + +source_test_functions "${TEST_DIR}/formatting.sh" nested heredoc quoted +[[ "$(nested)" == $'nested\ntail' ]] +[[ "$(heredoc)" == $'{\n "value": "}"\n}' ]] +[[ "$(quoted)" == $'}\n{\nquoted\n}\ntext' ]] +[[ ! -e "${TEST_DIR}/main-ran" ]] + +printf 'no_newline() {\n printf "loaded\\n"\n }' > "${TEST_DIR}/no-newline.sh" +source_test_functions "${TEST_DIR}/no-newline.sh" no_newline +[[ "$(no_newline)" == loaded ]] + +cat > "${TEST_DIR}/trailing.sh" <<'EOF' +trailing() { + : +}; printf 'executed\n' > "${TEST_DIR}/main-ran" +trailing_compound() { + : +}; printf 'executed\n' > "${TEST_DIR}/main-ran"; another() { + : +} +redirected() { + : +} > "${TEST_DIR}/main-ran" +pending_heredoc() { + cat <<'PAYLOAD'; } +} +PAYLOAD +printf 'executed\n' > "${TEST_DIR}/main-ran" +EOF + +for name in trailing trailing_compound redirected pending_heredoc; do + assert_extraction_fails "${TEST_DIR}/trailing.sh" "${name}" + if declare -F "${name}" >/dev/null; then + printf 'Rejected function was defined: %s\n' "${name}" >&2 + exit 1 + fi + [[ ! -e "${TEST_DIR}/main-ran" ]] +done + +echo "Function extraction contract tests passed" diff --git a/acl/tests/ipe/offline/test-ipe-azure-launch-contract.sh b/acl/tests/ipe/offline/test-ipe-azure-launch-contract.sh new file mode 100755 index 00000000000..d033dc1274e --- /dev/null +++ b/acl/tests/ipe/offline/test-ipe-azure-launch-contract.sh @@ -0,0 +1,549 @@ +#!/bin/bash +# shellcheck disable=SC1091,SC2016,SC2034 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)" +TEST_DIR="$(mktemp -d)" +trap 'rm -rf "${TEST_DIR}"' EXIT + +# shellcheck source=../../../../build_library/rpm/ipe_artifact.sh +source "${SCRIPT_DIR}/build_library/rpm/ipe_artifact.sh" +source "${SCRIPT_DIR}/acl/tests/ipe/offline/function-extraction.sh" + +expect_failure() { + if "$@" >/dev/null 2>&1; then + echo "Expected failure: $*" >&2 + return 1 + fi +} + +create_certificate() { + local directory="$1" + local subject='/CN=IPE Azure launch test/' + if [[ -n "${MSYSTEM:-}" && "${MSYS2_ARG_CONV_EXCL:-}" != "*" ]]; then + subject='//CN=IPE Azure launch test' + fi + mkdir -p "${directory}" + openssl req -x509 -newkey rsa:2048 -nodes \ + -subj "${subject}" \ + -keyout "${directory}/ca.key" \ + -out "${directory}/uki-signing-ca.pem" \ + -days 1 >/dev/null 2>&1 +} + +prepare_vhd() { + local directory="$1" + mkdir -p "${directory}" + printf 'vhd\n' > "${directory}/acl_production_azure_test_image.vhd" +} + +test_marker_contract() { + local directory="${TEST_DIR}/marker" + mkdir -p "${directory}" + + printf 'ephemeral\n' > "${directory}/ipe-signing-mode" + [[ "$(ipe_resolve_artifact_signing_mode "${directory}")" == "ephemeral" ]] + printf 'esrp\n' > "${directory}/ipe-signing-mode" + [[ "$(ipe_resolve_artifact_signing_mode "${directory}")" == "esrp" ]] + + printf 'esrp' > "${directory}/ipe-signing-mode" + expect_failure ipe_resolve_artifact_signing_mode "${directory}" + printf 'esrp\n\n' > "${directory}/ipe-signing-mode" + expect_failure ipe_resolve_artifact_signing_mode "${directory}" + printf 'esrp\r\n' > "${directory}/ipe-signing-mode" + expect_failure ipe_resolve_artifact_signing_mode "${directory}" + + rm -f "${directory}/ipe-signing-mode" + if [[ -z "${MSYSTEM:-}" ]] && + ln -s missing "${directory}/ipe-signing-mode" 2>/dev/null; then + expect_failure ipe_resolve_artifact_signing_mode "${directory}" + rm -f "${directory}/ipe-signing-mode" + fi + mkdir "${directory}/ipe-signing-mode" + expect_failure ipe_resolve_artifact_signing_mode "${directory}" + rmdir "${directory}/ipe-signing-mode" + + [[ "$(ipe_resolve_artifact_signing_mode "${directory}")" == "disabled" ]] + expect_failure ipe_resolve_artifact_signing_mode "${directory}" true + mkdir "${directory}/acl-ipe-policy" + expect_failure ipe_resolve_artifact_signing_mode "${directory}" +} + +test_local_vhd_contract() { + local directory="${TEST_DIR}/vhd" + local image="${directory}/acl_production_azure_test_image.vhd" + prepare_vhd "${directory}" + + [[ "$(ipe_validate_local_vhd_contract "${image}")" == "disabled" ]] + create_certificate "${directory}" + [[ "$(ipe_validate_local_vhd_contract "${image}")" == "disabled" ]] + + printf 'ephemeral\n' > "${directory}/ipe-signing-mode" + [[ "$(ipe_validate_local_vhd_contract "${image}")" == "ephemeral" ]] + + ipe_validate_signing_certificate "${directory}/uki-signing-ca.pem" + printf 'not-a-certificate\n' > "${directory}/uki-signing-ca.pem" + expect_failure ipe_validate_signing_certificate "${directory}/uki-signing-ca.pem" + create_certificate "${directory}" + mv "${directory}/uki-signing-ca.pem" "${directory}/real-cert.pem" + if [[ -z "${MSYSTEM:-}" ]] && + ln -s real-cert.pem "${directory}/uki-signing-ca.pem" 2>/dev/null; then + expect_failure ipe_validate_signing_certificate "${directory}/uki-signing-ca.pem" + fi + rm -f "${directory}/uki-signing-ca.pem" + expect_failure ipe_validate_signing_certificate "${directory}/uki-signing-ca.pem" + printf 'esrp\n' > "${directory}/ipe-signing-mode" + [[ "$(ipe_validate_local_vhd_contract "${image}" true)" == esrp ]] + expect_failure ipe_validate_local_vhd_contract "${image}" false + : > "${image}" + expect_failure ipe_validate_local_vhd_contract "${image}" true +} + +test_argument_contracts() { + local argument option + local allowed=( + "--security-type Standard" + "--secu=Standard" + "--security-t=Standard" + "--enable-vtpm false" + "--enable-v=false" + "--enable-secure-boot false" + "--enable-s=false" + "--enable-secure-b=false" + "--size Standard_D2s_v5" + "--si=Standard_D2s_v5" + "--siz=Standard_D2s_v5" + "--imds-mode Enforced" + "--imds-profile-id=profile" + "--include-zones" + "-o json" + "-z 1" + ) + + for option in -i --i --im --ima --imag --image; do + for argument in "${option} other" "${option}=other"; do + local -a parsed=() + ipe_split_argument_string parsed "${argument}" + expect_failure ipe_reject_azure_image_overrides "${parsed[@]}" + done + done + expect_failure ipe_reject_azure_image_overrides -iother + for argument in "${allowed[@]}"; do + local -a parsed=() + ipe_split_argument_string parsed "${argument}" + ipe_reject_azure_image_overrides "${parsed[@]}" + done + ipe_reject_azure_image_overrides --user-data config.ign + + local -a multiline=() + ipe_split_argument_string multiline $'--priority Regular\n--user-data config.ign' + [[ "${multiline[*]}" == "--priority Regular --user-data config.ign" ]] + +} + +test_build_wrapper_contract() { + local build_script="${SCRIPT_DIR}/acl/build_rpm_image.sh" + source_test_functions "${build_script}" validate_ipe_boot_path + error() { :; } + + ACL_IPE_CAPABLE=true + BOOTLOADER_MODE=uki + SECURE_BOOT_ENABLED=true + AZ_VM_ARGS="--user-data config.ign" + validate_ipe_boot_path azure + SECURE_BOOT_ENABLED=false + validate_ipe_boot_path azure + SECURE_BOOT_ENABLED=true + + AZ_VM_ARGS="--enable-secure-boot false" + validate_ipe_boot_path azure + local option + for option in --i --im --ima --imag --image; do + for AZ_VM_ARGS in "${option} other" "${option}=other"; do + ACL_IPE_CAPABLE=true + expect_failure validate_ipe_boot_path azure + ACL_IPE_CAPABLE=false + validate_ipe_boot_path azure + done + done + ACL_IPE_CAPABLE=true + AZ_VM_ARGS="--imds-mode Enforced --imds-profile-id=profile" + validate_ipe_boot_path azure +} + +test_artifact_preflight_ordering() { + local validate_common="${SCRIPT_DIR}/acl/validate/validate_common.sh" + local image_to_vm="${SCRIPT_DIR}/image_to_vm.sh" + local preflight_line cleanup_line + + preflight_line="$(grep -nF '_prepare_local_ipe_artifact_contract "${vm_image_path}"' "${validate_common}" | cut -d: -f1)" + cleanup_line="$(grep -nF ' remove_old_vm' "${validate_common}" | cut -d: -f1)" + [[ -n "${preflight_line}" && -n "${cleanup_line}" ]] + [[ "${preflight_line}" -lt "${cleanup_line}" ]] + grep -Fq 'printf '\''%s\n'\'' "${ipe_signing_mode}" > "$(_dst_dir)/ipe-signing-mode"' \ + "${image_to_vm}" +} + +# shellcheck disable=SC2329 +test_launch_contract_before_side_effects() ( + source_test_functions "${SCRIPT_DIR}/acl/validate/validate_common.sh" start_vm + source_test_functions "${SCRIPT_DIR}/acl/validate/validate_azure.sh" \ + _prepare_local_ipe_artifact_contract _enforce_arm_security_contract \ + _enforce_ipe_image_contract start_vm_azure + local directory="${TEST_DIR}/launch-order" + local effects="${directory}/effects" rc entrypoint option + local -a overrides=(-iother) + for option in -i --i --im --ima --imag --image; do + overrides+=("${option} other" "${option}=other") + done + prepare_vhd "${directory}" + printf 'ephemeral\n' > "${directory}/ipe-signing-mode" + local image="${directory}/acl_production_azure_test_image.vhd" + local VM_TYPE=azure BOARD=amd64-usr VM_NAME=test + local ACG_IMAGE_VERSION_ID="" REUSE_IMAGE=false SECURE_BOOT_ENABLED=false + local ACL_IPE_CAPABLE="" AZ_VM_ARGS="" _LOCAL_IPE_ARTIFACT_PATH="" + + error() { echo "$*" >&2; } + die() { error "$*"; exit 1; } + remove_old_vm() { echo cleanup >> "${effects}"; } + section() { echo section >> "${effects}"; exit 97; } + az() { echo az >> "${effects}"; exit 98; } + + for entrypoint in start_vm start_vm_azure; do + for AZ_VM_ARGS in "${overrides[@]}"; do + ACL_IPE_CAPABLE="" + rc=0 + ("${entrypoint}" "${image}" "${BOARD}") >/dev/null 2>&1 || rc=$? + [[ "${rc}" -eq 1 && ! -e "${effects}" ]] + done + + BOARD=arm64-usr + AZ_VM_ARGS="--user-data=config.ign --siz=Standard_D2ps_v5" + rc=0 + ("${entrypoint}" "${image}" "${BOARD}") >"${directory}/arm-result" 2>&1 || rc=$? + [[ "${rc}" -eq 1 && ! -e "${effects}" ]] + grep -Fq -- '--az-vm-args cannot override size or Trusted Launch security settings for Azure ARM VMs' \ + "${directory}/arm-result" + BOARD=amd64-usr + + ACL_IPE_CAPABLE=true + AZ_VM_ARGS="" + REUSE_IMAGE=true + rc=0 + ("${entrypoint}" "${image}" "${BOARD}") >/dev/null 2>&1 || rc=$? + [[ "${rc}" -eq 1 && ! -e "${effects}" ]] + REUSE_IMAGE=false + + ACG_IMAGE_VERSION_ID=explicit-version + AZ_VM_ARGS="--image other" + rc=0 + ("${entrypoint}" "${image}" "${BOARD}") >/dev/null 2>&1 || rc=$? + [[ "${rc}" -eq 1 && ! -e "${effects}" ]] + ACG_IMAGE_VERSION_ID="" + done +) + +# shellcheck disable=SC2329 +test_gallery_collision_before_cleanup() ( + source_test_functions "${SCRIPT_DIR}/acl/validate/validate_common.sh" start_vm remove_old_vm + source_test_functions "${SCRIPT_DIR}/acl/validate/validate_azure.sh" \ + _prepare_local_ipe_artifact_contract _enforce_arm_security_contract \ + _enforce_ipe_image_contract get_next_image_version \ + gallery_image_version_exists remove_vm_azure + local directory="${TEST_DIR}/gallery-collision" + local image="${directory}/acl_production_azure_test_image.vhd" + local effects="${directory}/effects" result="${directory}/result" + local mode rc + prepare_vhd "${directory}" + + local VM_TYPE=azure BOARD=amd64-usr VM_NAME=test + local ACG_IMAGE_VERSION_ID="" REUSE_IMAGE=false SECURE_BOOT_ENABLED=false + local ACL_IPE_CAPABLE="" AZ_VM_ARGS="" BUILD_ID=52 + local AZ_SUB_ID=test-subscription AZ_GALLERY_RG=test-gallery-rg + local AZ_ACG=test-gallery AZ_VM_IMAGE_DEF=test-image + local NO_CLEANUP=false _LOCAL_IPE_SIGNING_MODE=disabled + local _LOCAL_IPE_ARTIFACT_PATH="" VERSION_STATE=Succeeded + local -a RESOURCE_TAGS=(owner=test) + + error() { echo "$*" >&2; } + die() { error "$*"; exit 1; } + info() { :; } + section() { echo section >> "${effects}"; } + remove_vm_state() { echo state-removal >> "${effects}"; } + start_vm_azure() { echo vm-start >> "${effects}"; } + az() { + case "$1 $2 $3" in + "sig image-version show") + echo lookup >> "${effects}" + [[ " $* " == *" --subscription test-subscription "* && + " $* " == *" --gallery-image-version 1.0.52 "* ]] || return 2 + [[ "${VERSION_STATE}" == "Succeeded" ]] || return 3 + echo Succeeded + ;; + "group list --query") echo group-list >> "${effects}"; echo old-vm-rg ;; + "group delete -n") echo group-delete >> "${effects}" ;; + "account set --subscription") echo account-switch >> "${effects}" ;; + "storage blob upload") echo upload >> "${effects}" ;; + "vm create") echo vm-create >> "${effects}" ;; + *) echo "Unexpected Azure call: $*" >&2; return 2 ;; + esac + } + run_launch() { + local expected_rc="$1" + rc=0 + (start_vm "${image}" "${BOARD}") > "${result}" 2>&1 || rc=$? + if [[ "${rc}" -ne "${expected_rc}" ]]; then + echo "Unexpected launch exit ${rc} (expected ${expected_rc})" >&2 + printf 'Launch output:\n%s\n' "$(<"${result}")" >&2 + return 1 + fi + } + + for mode in ephemeral esrp; do + printf '%s\n' "${mode}" > "${directory}/ipe-signing-mode" + : > "${effects}" + run_launch 1 + grep -Fq 'Refusing to reuse gallery image version 1.0.52 for an IPE-capable local VHD' "${result}" + [[ "$(<"${effects}")" == lookup ]] + done + + VERSION_STATE=Missing + : > "${effects}" + run_launch 0 + [[ "$(<"${effects}")" == $'lookup\nstate-removal\ngroup-list\ngroup-delete\nsection\nvm-start' ]] + + VERSION_STATE=Succeeded + BUILD_ID="" + : > "${effects}" + run_launch 0 + [[ "$(<"${effects}")" == $'state-removal\ngroup-list\ngroup-delete\nsection\nvm-start' ]] + + BUILD_ID=52 + rm "${directory}/ipe-signing-mode" + ACL_IPE_CAPABLE="" + : > "${effects}" + run_launch 0 + [[ "$(<"${effects}")" == $'state-removal\ngroup-list\ngroup-delete\nsection\nvm-start' ]] + + ACG_IMAGE_VERSION_ID=explicit-version + : > "${effects}" + run_launch 0 + [[ "$(<"${effects}")" == $'state-removal\ngroup-list\ngroup-delete\nsection\nvm-start' ]] + + ACG_IMAGE_VERSION_ID="" + REUSE_IMAGE=true + : > "${effects}" + run_launch 0 + [[ "$(<"${effects}")" == $'state-removal\ngroup-list\ngroup-delete\nsection\nvm-start' ]] +) + +# shellcheck disable=SC2329 +test_ipe_upload_uses_preflighted_artifact() ( + source_test_functions "${SCRIPT_DIR}/acl/validate/validate_common.sh" start_vm + source_test_functions "${SCRIPT_DIR}/acl/validate/validate_azure.sh" \ + _prepare_local_ipe_artifact_contract _enforce_arm_security_contract \ + _enforce_ipe_image_contract start_vm_azure get_next_image_version + local directory="${TEST_DIR}/pinned-artifact" + local first="${directory}/build-a" second="${directory}/build-b" + local latest="${directory}/latest" + local image="${latest}/image.vhd" + local uploaded="${directory}/uploaded" phase + mkdir -p "${first}" "${second}" + printf 'VHD A\n' > "${first}/image.vhd" + printf 'VHD B\n' > "${second}/image.vhd" + printf 'ephemeral\n' > "${first}/ipe-signing-mode" + printf 'ephemeral\n' > "${second}/ipe-signing-mode" + create_certificate "${first}" + create_certificate "${second}" + link_latest() { + local target="$1" + local win_latest win_target + [[ ! -e "${latest}" && ! -L "${latest}" ]] || rm "${latest}" + if MSYS=winsymlinks:nativestrict ln -s "$(basename "${target}")" "${latest}" 2>/dev/null && + [[ -L "${latest}" ]]; then + return 0 + fi + rm -f "${latest}" + if [[ -n "${MSYSTEM:-}" ]] && command -v powershell.exe >/dev/null; then + win_latest="$(cygpath -w "${latest}")" + win_target="$(cygpath -w "${target}")" + powershell.exe -NoProfile -NonInteractive -Command \ + "New-Item -ItemType Junction -Path '${win_latest}' -Target '${win_target}' | Out-Null" \ + >/dev/null 2>&1 && [[ -L "${latest}" ]] + else + return 1 + fi + } + if ! link_latest "${first}"; then + echo "SKIP: VHD symlink retarget checks (native symlinks unavailable)" >&2 + return 0 + fi + local first_physical + first_physical="$(cd -P "${first}" && pwd -P)" + + local VM_TYPE=azure BOARD=amd64-usr VM_NAME=test IMG_NAME=test + local ACG_IMAGE_VERSION_ID="" REUSE_IMAGE=false SECURE_BOOT_ENABLED=true + local ACL_IPE_CAPABLE="" AZ_VM_ARGS="" BUILD_ID=52 + local AZ_SUB_ID=test-subscription AZ_STORAGE_RG=test-rg AZ_REGION=test-region + local AZ_ACG=test-gallery AZ_VM_IMAGE_DEF=test-image AZ_VM_SIZE=test-size + local VM_RG="" VM_IP="" _LOCAL_IPE_ARTIFACT_PATH="" + local _LOCAL_IPE_SIGNING_MODE=disabled _LOCAL_IPE_SIGNING_CERT="" + + error() { echo "$*" >&2; } + die() { error "$*"; exit 1; } + info() { :; } + section() { :; } + get_vm_rg_name() { echo test-rg; } + gallery_image_version_exists() { return 1; } + remove_old_vm() { + [[ "${phase}" == cleanup ]] && link_latest "${second}" + return 0 + } + check_azure_infra() { + [[ "${phase}" == infra ]] && link_latest "${second}" + return 0 + } + upload_vhd_to_storage() { + printf '%s\n' "$1" "$(<"$1")" "${_LOCAL_IPE_SIGNING_CERT}" > "${uploaded}" + } + create_gallery_image_version() { :; } + create_vm_azure() { :; } + az() { + case "$1 $2 $3" in + "account set --subscription") ;; + "vm show -d") + if [[ "$*" == *"provisioningState"* ]]; then + echo Succeeded + else + echo 192.0.2.1 + fi + ;; + *) echo "Unexpected Azure call: $*" >&2; return 1 ;; + esac + } + + phase=cleanup + start_vm "${image}" "${BOARD}" + [[ "$(<"${uploaded}")" == "${first_physical}/image.vhd"$'\nVHD A\n'"${first_physical}/uki-signing-ca.pem" ]] + [[ "$(<"${image}")" == "VHD B" ]] + + link_latest "${first}" + _LOCAL_IPE_ARTIFACT_PATH="" + _LOCAL_IPE_SIGNING_MODE=disabled + ACL_IPE_CAPABLE="" + phase=infra + start_vm_azure "${image}" + [[ "$(<"${uploaded}")" == "${first_physical}/image.vhd"$'\nVHD A\n'"${first_physical}/uki-signing-ca.pem" ]] + [[ "$(<"${image}")" == "VHD B" ]] + + if MSYS=winsymlinks:nativestrict ln -s image.vhd "${first}/linked.vhd" 2>/dev/null && + [[ -L "${first}/linked.vhd" ]]; then + if (start_vm "${first}/linked.vhd" "${BOARD}") > "${directory}/error" 2>&1; then + echo "IPE launch accepted a symlinked VHD file" >&2 + return 1 + fi + grep -Fq 'Azure VHD must be a readable, nonempty regular file' "${directory}/error" + else + echo "SKIP: leaf VHD symlink check (native symlinks unavailable)" >&2 + fi + + rm "${first}/ipe-signing-mode" "${second}/ipe-signing-mode" + link_latest "${first}" + ACL_IPE_CAPABLE="" + phase=cleanup + start_vm "${image}" "${BOARD}" + [[ "$(<"${uploaded}")" == "${image}"$'\nVHD B' ]] +) + +# shellcheck disable=SC2329 +test_non_ipe_reuse_and_explicit_ipe_version() ( + source_test_functions "${SCRIPT_DIR}/acl/validate/validate_azure.sh" \ + _enforce_ipe_image_contract start_vm_azure get_latest_image_version + local calls="${TEST_DIR}/gallery-selection" + local ACL_IPE_CAPABLE=false REUSE_IMAGE=true ACG_IMAGE_VERSION_ID="" AZ_VM_ARGS="" + local BOARD=amd64-usr VM_NAME=test AZ_SUB_ID=test AZ_STORAGE_RG=test AZ_REGION=test + local AZ_ACG=test AZ_VM_IMAGE_DEF=test AZ_VM_SIZE=test AZ_GALLERY_RG=test + local VM_RG="" VM_IP="" + + info() { :; } + section() { :; } + error() { echo "$*" >&2; } + die() { error "$*"; exit 1; } + get_vm_rg_name() { echo test-rg; } + check_azure_infra() { :; } + create_vm_azure() { printf '%s\n' "$2" >> "${calls}"; } + az() { + case "$1 $2 $3" in + "account set --subscription") ;; + "sig image-version list") printf '1.0.1\n1.0.2\n' ;; + "vm show -d") + if [[ "$*" == *"--query provisioningState"* ]]; then + echo Succeeded + else + echo 192.0.2.1 + fi + ;; + *) echo "Unexpected Azure call: $*" >&2; return 1 ;; + esac + } + + start_vm_azure missing-local-image + [[ "$(<"${calls}")" == 1.0.2 ]] + ACL_IPE_CAPABLE=true + REUSE_IMAGE=false + ACG_IMAGE_VERSION_ID=explicit-version + start_vm_azure missing-local-image + [[ "$(<"${calls}")" == $'1.0.2\nexplicit-version' ]] +) + +# shellcheck disable=SC2329 +check_build_test_selection() ( + local expected_secure_boot_tests="$1" + shift + source_test_functions "${SCRIPT_DIR}/acl/build_rpm_image.sh" \ + parse_args validate_ipe_boot_path + validate_ipe_mode_value() { :; } + load_reused_vm_type() { :; } + configure_ipe_mode() { :; } + operation_uses_local_image_artifact() { return 1; } + operation_uses_gallery_image() { return 1; } + operation_uses_vm_image() { return 0; } + error() { echo "$*" >&2; } + + local BOARD=amd64-usr GROUP=production VM_TYPE=azure RETRY_ATTEMPTS=0 + local BUILD_VM_IMAGE=false START_VM=false REUSE_IMAGE=false ACG_IMAGE_VERSION_ID="" + local ACL_IPE_CAPABLE=true BOOTLOADER_MODE=uki AZ_VM_ARGS="" SECURE_BOOT_ENABLED=true + local -a RUN_SCRIPTS=() RUN_HOST_SCRIPTS=() + unset RUN_TESTS + parse_args "$@" + + local script secure_boot_tests=0 + for script in "${RUN_SCRIPTS[@]}"; do + if [[ "${script}" == "./acl/tests/run-secureboot-test.sh" ]]; then + secure_boot_tests=$((secure_boot_tests + 1)) + fi + done + [[ "${secure_boot_tests}" == "${expected_secure_boot_tests}" ]] + if [[ "${RUN_TESTS:-false}" == true ]]; then + [[ " ${RUN_HOST_SCRIPTS[*]} " == *" ./acl/tests/ipe/run-ipe-mode-toggle-test.sh "* ]] + fi +) + +test_marker_contract +test_local_vhd_contract +test_argument_contracts +test_build_wrapper_contract +test_artifact_preflight_ordering +test_launch_contract_before_side_effects +test_gallery_collision_before_cleanup +test_ipe_upload_uses_preflighted_artifact +test_non_ipe_reuse_and_explicit_ipe_version +check_build_test_selection 1 --run-tests +check_build_test_selection 1 --run-tests --no-secure-boot +check_build_test_selection 1 --no-secure-boot --run-tests +check_build_test_selection 2 --run-tests --no-secure-boot --run-script=./acl/tests/run-secureboot-test.sh +check_build_test_selection 1 --board=arm64-usr --run-tests --no-secure-boot +check_build_test_selection 0 --run-script=custom-test.sh + +echo "IPE Azure launch contract tests 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..ab589bc5d95 --- /dev/null +++ b/acl/tests/ipe/offline/test-ipe-loader-runtime.sh @@ -0,0 +1,389 @@ +#!/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}" +} + +write_hashed_cmdline() { + local credential="$1" prefix="${2:-}" hash + hash="$(sha256sum "${credential}" | cut -d' ' -f1)" || return 1 + printf '%sacl.ipe.policy_sha256=%s\n' \ + "${prefix:+${prefix} }" "${hash}" > "${CASE_DIR}/cmdline" +} + +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" + + command cat > "${CASE_DIR}/security-profile.sh" <<'EOF' +acl_security_profile() { + printf '%s\n' 'ipe=audit' +} +acl_security_profile_value() { + printf '%s\n' 'audit' +} +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" + write_hashed_cmdline "${CASE_DIR}/credential.p7b.cred" "flatcar.oem.id=azure" + + # Override IMDS to return the requested inactive-style mode + command cat > "${CASE_DIR}/security-profile.sh" < "${CASE_DIR}/security-profile.sh" < "${ACL_IPE_DIR}/policies/acl_ipe_boot_policy/active" + fi + } + export -f cat + + local output + output="$(run_loader "${CASE_DIR}/credential.p7b.cred")" + 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" + write_hashed_cmdline "${CASE_DIR}/credential.p7b.cred" "flatcar.oem.id=azure" + + command 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 + make_credential "${CASE_DIR}/credential.p7b.cred" + printf 'acl.ipe.policy_sha256=%s\n' "${ZERO_HASH}" > "${CASE_DIR}/cmdline" + assert_best_effort_skip "credential SHA-256 mismatch: expected=${ZERO_HASH}" + [[ ! -s "${IPE_DIR}/new_policy" ]] + [[ "$(<"${IPE_DIR}/enforce")" == "9" ]] +} + +# ---- 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 + write_hashed_cmdline "${CASE_DIR}/real.cred" + 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" + write_hashed_cmdline "${CASE_DIR}/credential.p7b.cred" + # 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" + write_hashed_cmdline "${CASE_DIR}/credential.p7b.cred" + # 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" + write_hashed_cmdline "${CASE_DIR}/credential.p7b.cred" + 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" + write_hashed_cmdline "${CASE_DIR}/credential.p7b.cred" + 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" + write_hashed_cmdline "${CASE_DIR}/credential.p7b.cred" + 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" + write_hashed_cmdline "${CASE_DIR}/credential.p7b.cred" + command 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_hashed_cmdline_helper() { + prepare_case hashed-cmdline + make_credential "${CASE_DIR}/credential.p7b.cred" + local expected_hash + expected_hash="$(sha256sum "${CASE_DIR}/credential.p7b.cred" | cut -d' ' -f1)" + + write_hashed_cmdline "${CASE_DIR}/credential.p7b.cred" "flatcar.oem.id=azure" + [[ "$(<"${CASE_DIR}/cmdline")" == "flatcar.oem.id=azure acl.ipe.policy_sha256=${expected_hash}" ]] + + write_hashed_cmdline "${CASE_DIR}/credential.p7b.cred" + [[ "$(<"${CASE_DIR}/cmdline")" == "acl.ipe.policy_sha256=${expected_hash}" ]] +} + +test_hashed_cmdline_helper +test_absent_token +test_missing_cmdline +test_valid_inactive_load +test_valid_disabled_alias_inactive +test_removed_alias_inactive +test_valid_audit_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..e18aff3ca13 --- /dev/null +++ b/acl/tests/ipe/offline/test-ipe-mode-toggle-logic.sh @@ -0,0 +1,359 @@ +#!/bin/bash +# shellcheck disable=SC2034 # Variables below are consumed by sourced functions. +# 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:audit" \ + "permissive:off" "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_get_ipe_mode_reports_audit() ( + ssh_cmd() { + case "$*" in + *"test -r"*) return 0 ;; + *"/active"*) echo 1 ;; + *"/enforce"*) echo 0 ;; + *) echo "Unexpected guest command: $*" >&2; return 1 ;; + esac + } + [[ "$(get_ipe_mode)" == audit ]] +) + +test_assert_ipe_mode_matches() { + get_ipe_mode() { echo "off"; } + assert_ipe_mode "off" + get_ipe_mode() { echo "audit"; } + assert_ipe_mode "audit" +} + +test_assert_ipe_mode_mismatch_fails() { + get_ipe_mode() { echo "off"; } + if assert_ipe_mode "audit" 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 "audit"; } + 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_security_profile_updates_preserve_unrelated_keys() { + [[ "$( + security_profile_with_key 'ipe=audit,selinux=enforcing,foo=bar' \ + 'selinux' 'permissive' + )" == "ipe=audit,foo=bar,selinux=permissive" ]] + [[ "$( + security_profile_with_key 'selinux=enforcing,foo=bar' 'ipe' 'disabled' + )" == "selinux=enforcing,foo=bar,ipe=disabled" ]] + [[ "$(security_profile_with_key '' 'ipe' 'audit')" == "ipe=audit" ]] +} + +test_tag_state_distinguishes_absent_from_empty() ( + local call_log="${TMPDIR:-/tmp}/security-profile-tag-calls.$$" + local absent_state present_empty_state + trap 'rm -f "${call_log}"' EXIT + + absent_state='{"present":false,"value":""}' + present_empty_state='{"present":true,"value":""}' + VM_RG="test-rg" + VM_NAME="test-vm" + imds_security_profile_state() { printf '%s\n' "${MOCK_IMDS_STATE}"; } + az() { + if [[ "$1 $2" == "vm show" ]]; then + printf '%s\n' "/subscriptions/test/resourceGroups/test/providers/Microsoft.Compute/virtualMachines/test" + return 0 + fi + printf '%s\n' "$*" >> "${call_log}" + } + + MOCK_IMDS_STATE="${absent_state}" + set_security_profile_tag_state "${absent_state}" + grep -Fq 'tag update' "${call_log}" + grep -Fq -- '--operation delete' "${call_log}" + + : > "${call_log}" + MOCK_IMDS_STATE="${present_empty_state}" + set_security_profile_tag_state "${present_empty_state}" + grep -Fq -- '--operation merge' "${call_log}" + grep -Fq -- '--tags acl-node-security-profile=' "${call_log}" +) + +test_host_imds_rejects_multiple_documents() ( + ssh_cmd() { printf '%s' '[{"name":"acl-node-security-profile","value":"ipe=audit"}] []'; } + + if imds_security_profile_state >/dev/null 2>&1; then + echo "host IMDS parser accepted multiple JSON documents" >&2 + return 1 + fi +) + +test_shared_cmdline_fields() { + local cmdline='usrhash=ABC usrhash=DEF acl.ipe.policy_sha256=123 systemd.verity_usr_options=foo=bar,root-hash-signature=/expected,other=value' + + [[ "$(ipe_cmdline_field "${cmdline}" usrhash)" == "ABC" ]] + [[ "$(ipe_cmdline_field "${cmdline}" acl.ipe.policy_sha256)" == "123" ]] + [[ -z "$(ipe_cmdline_field "${cmdline}" unknown)" ]] + [[ "$(ipe_cmdline_field \ + "$(ipe_cmdline_field "${cmdline}" systemd.verity_usr_options)" \ + root-hash-signature ',')" == "/expected" ]] +} + +test_policy_only_cmdline_contract() { + local cmdline + if ipe_has_root_hash_signature \ + 'usrhash=abc systemd.verity_usr_options=hash-offset=4096,panic-on-corruption'; then + echo "unsigned verity was classified as requiring a signature" >&2 + return 1 + fi + for cmdline in \ + 'root-hash-signature=/unexpected' \ + 'systemd.verity_usr_options=root-hash-signature=/unexpected' \ + 'systemd.verity_usr_options=hash-offset=4096,root-hash-signature=,panic-on-corruption' \ + 'systemd.verity_usr_options=hash-offset=4096 systemd.verity_usr_options=root-hash-signature=/unexpected'; do + ipe_has_root_hash_signature "${cmdline}" || { + echo "required root signature was missed: ${cmdline}" >&2 + return 1 + } + done +} + +test_probe_denial_correlation() { + local event='type=1420 ipe_op=EXECUTE ipe_hook=BPRM_CHECK enforcing=0 pid=123 comm="true" path="/usr/bin/true" rule="DEFAULT op=EXECUTE action=DENY"' + local invalid + [[ "$(ipe_find_denial_event "${event}" /usr/bin/true 123)" == "${event}" ]] + for invalid in \ + "${event/pid=123/pid=1234}" \ + "${event/\/usr\/bin\/true/\/var\/tmp\/true}" \ + "${event/enforcing=0/enforcing=1}" \ + "${event/ipe_op=EXECUTE/ipe_op=READ}" \ + "${event/action=DENY/action=ALLOW}" \ + ""; do + if ipe_find_denial_event "${invalid}" /usr/bin/true 123 >/dev/null; then + echo "unrelated or missing audit record satisfied the /usr probe" >&2 + return 1 + fi + done +} + +test_host_imds_matches_guest_parser() ( + acl_usrbin() { command "$@"; } + local response expected_state expected_value + + for response in \ + '[]' \ + '[{"name":"acl-node-security-profile","value":""}]' \ + '[{"name":"acl-node-security-profile","value":"ipe=audit"}]'; do + ssh_cmd() { printf '%s' "${response}"; } + expected_state="$(imds_security_profile_state)" + expected_value="$(jq -r '.value' <<< "${expected_state}")" + [[ "$(printf '%s' "${response}" | acl_security_profile_parse)" == "${expected_value}" ]] + done + [[ "$(jq -r '.present' <<< "${expected_state}")" == "true" ]] + ssh_cmd() { printf '%s' '[{"name":"acl-node-security-profile","value":""}]'; } + [[ "$(jq -r '.present' <<< "$(imds_security_profile_state)")" == "true" ]] + ssh_cmd() { printf '%s' '[]'; } + [[ "$(jq -r '.present' <<< "$(imds_security_profile_state)")" == "false" ]] + + for response in \ + '{}' \ + '[{"name":"acl-node-security-profile","value":null}]' \ + '[{"name":"acl-node-security-profile","value":"ipe=audit"},{"name":"acl-node-security-profile","value":"ipe=off"}]' \ + '[{"name":"acl-node-security-profile","value":"ipe=audit"}] []'; do + ssh_cmd() { printf '%s' "${response}"; } + if imds_security_profile_state >/dev/null 2>&1 || + printf '%s' "${response}" | acl_security_profile_parse >/dev/null 2>&1; then + echo "host or guest accepted invalid IMDS response: ${response}" >&2 + return 1 + fi + done +) + +mock_guest_cmdline() { + cat() { + if [[ "$1" == "/proc/cmdline" ]]; then + printf 'usrhash=not-a-hash\n' + else + command cat "$@" + fi + } + export -f cat +} + +test_streamed_guest_contains_cmdline_parser() ( + local payload="${TMPDIR:-/tmp}/ipe-guest-payload.$$" + trap 'rm -f "${payload}" "${payload}.out"' EXIT + VM_SSH_USER=tester + VM_IP=test-vm + ssh() { cat > "${payload}"; } + + run_audit_validation + bash -n "${payload}" + grep -Fq 'ipe_cmdline_field() {' "${payload}" + grep -Fq 'usr_hash="$(ipe_cmdline_field "${cmdline}" usrhash)"' "${payload}" + mock_guest_cmdline + if bash -s < "${payload}" > "${payload}.out" 2>&1; then + echo "Streamed guest script accepted an invalid root hash" >&2 + return 1 + fi + grep -Fq 'FAILED: could not read the /usr dm-verity SHA-256 root hash from the command line' \ + "${payload}.out" +) + +test_copied_guest_script_needs_no_sibling_file() ( + local test_dir script output_log + test_dir="$(mktemp -d)" + trap 'rm -rf "${test_dir}"' EXIT + script="${SCRIPT_DIR}/acl/tests/ipe/run-ipe-audit-test.sh" + output_log="${test_dir}/output" + VM_SSH_USER=tester + VM_SSH_KEY=unused + SCRIPT_RESULTS_NAMES=() + SCRIPT_RESULTS_STATUS=() + info() { :; } + error() { :; } + scp() { + local source_file="${@: -2:1}" + cp "${source_file}" "${test_dir}/$(basename "${source_file}")" + } + ssh() { + mock_guest_cmdline + bash "${test_dir}/$(basename "${script}")" > "${output_log}" 2>&1 + } + + if run_scripts_on_vm test-vm "${script}"; then + echo "Guest script accepted an invalid root hash" >&2 + return 1 + fi + grep -Fq 'FAILED: could not read the /usr dm-verity SHA-256 root hash from the command line' \ + "${output_log}" + [[ "${SCRIPT_RESULTS_STATUS[0]}" -eq 1 ]] +) + +test_cleanup_preserves_primary_failure() { + local status + + set +e + ( + ORIGINAL_SECURITY_PROFILE_STATE='{"present":false,"value":""}' + SECURITY_PROFILE_MUTATED=true + azure_security_profile_state() { printf '%s\n' '{"present":true,"value":"ipe=audit"}'; } + set_security_profile_tag_state() { return 1; } + section() { :; } + warn() { :; } + (exit 7) + restore_security_profile_on_exit + ) + status=$? + set -e + + [[ "${status}" -eq 7 ]] || { + echo "cleanup replaced primary status 7 with ${status}" >&2 + return 1 + } +} + +test_cleanup_reboots_after_any_mutation() { + local reboot_log="${TMPDIR:-/tmp}/security-profile-reboot.$$" + local status + rm -f "${reboot_log}" + + set +e + ( + ORIGINAL_SECURITY_PROFILE_STATE='{"present":true,"value":"selinux=enforcing"}' + SECURITY_PROFILE_MUTATED=true + set_security_profile_tag_state() { return 0; } + reboot_and_wait() { printf 'rebooted\n' > "${reboot_log}"; } + section() { :; } + info() { :; } + warn() { :; } + (exit 7) + restore_security_profile_on_exit + ) + status=$? + set -e + + [[ "${status}" -eq 7 ]] || { + echo "cleanup replaced primary status 7 with ${status}" >&2 + rm -f "${reboot_log}" + return 1 + } + [[ -s "${reboot_log}" ]] || { + echo "cleanup skipped reboot after a profile mutation" >&2 + rm -f "${reboot_log}" + return 1 + } + rm -f "${reboot_log}" +} + +test_expected_mode_for_tag +test_get_ipe_mode_reports_audit +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 +test_security_profile_updates_preserve_unrelated_keys +test_tag_state_distinguishes_absent_from_empty +test_host_imds_rejects_multiple_documents +test_shared_cmdline_fields +test_policy_only_cmdline_contract +test_probe_denial_correlation +test_host_imds_matches_guest_parser +test_streamed_guest_contains_cmdline_parser +test_copied_guest_script_needs_no_sibling_file +test_cleanup_preserves_primary_failure +test_cleanup_reboots_after_any_mutation + +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..c44edb5b7c0 --- /dev/null +++ b/acl/tests/ipe/offline/test-ipe-policy-input.sh @@ -0,0 +1,603 @@ +#!/bin/bash +# shellcheck disable=SC2034 # Variables below are consumed by extracted functions. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)" +TEST_DIR="$(mktemp -d)" +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_artifact.sh" +source "${SCRIPT_DIR}/acl/tests/ipe/offline/function-extraction.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_uki_provision_preserves_unsigned_verity() ( + source_test_functions "${SCRIPT_DIR}/build_library/rpm/uki_install.sh" \ + uki_provision_rpm _uki_build_verity_addons + sudo() { "$@"; } + _uki_build_firstboot_addon() { :; } + _uki_build_fips_addon() { :; } + _uki_build_kdump_addon() { :; } + _uki_build_debug_addon() { :; } + ukify() { + local argument output="" cmdline="" + for argument; do + case "${argument}" in + --cmdline=@*) cmdline="${argument#--cmdline=@}" ;; + --output=*) output="${argument#--output=}" ;; + esac + done + cp "${cmdline}" "${BUILD_DIR}/${output##*/}.cmdline" + cp "${cmdline}" "${output}" + } + + local scenario capable esp extra cmdline policy_hash failure + local slot addon_cmdline data_uuid hash_uuid + local uki_name="vmlinuz-6.6.145.2.efi" + local roothash="000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F" + FLAGS_TRUE=0 + for scenario in x64:false x64:true aa64:false aa64:true; do + EFI_ARCH="${scenario%:*}" + capable="${scenario#*:}" + prepare_case "uki-${EFI_ARCH}-${capable}" + IPE_CAPABLE="${capable}" + BOARD_ROOT="${CASE_ROOT}/board" + FLAGS_disk_image="${BUILD_DIR}/image.bin" + FLAGS_verity_hash="${BUILD_DIR}/usrhash" + FLAGS_fs_uuid="${BUILD_DIR}/fs_uuid" + FLAGS_verity_uuid="${BUILD_DIR}/verity_uuid" + esp="${CASE_ROOT}/esp" + extra="${esp}/EFI/Linux/${uki_name}.extra.d" + mkdir -p "${esp}/flatcar" "${extra}" \ + "${BOARD_ROOT}/usr/lib/systemd/boot/efi" \ + "${BOARD_ROOT}/boot/efi/EFI/BOOT" \ + "${BUILD_DIR}/acl-ipe-policy" + touch "${esp}/vmlinuz-6.6.145.2" "${esp}/flatcar/initramfs-a.img" \ + "${BOARD_ROOT}/usr/lib/systemd/boot/efi/linux${EFI_ARCH}.efi.stub" \ + "${BOARD_ROOT}/boot/efi/EFI/BOOT/boot${EFI_ARCH}.efi" \ + "${BOARD_ROOT}/boot/efi/EFI/BOOT/grub${EFI_ARCH}.efi" + printf 'policy credential\n' > "${BUILD_DIR}/acl-ipe-policy/acl-ipe-policy.p7b.cred" + printf 'stale\n' > "${extra}/acl-ipe-policy.p7b.cred" + printf '11111111-2222-3333-4444-555555555555\n' > "${FLAGS_fs_uuid}" + printf '66666666-7777-8888-9999-aaaaaaaaaaaa\n' > "${FLAGS_verity_uuid}" + + FLAGS_verity=0 + if failure="$(_uki_build_verity_addons "${esp}" "${uki_name}" 2>&1)"; then + echo "UKI addons accepted a missing /usr root hash" >&2 + return 1 + fi + [[ "${failure}" == *"Verity enabled but no hash file"* ]] + printf '%s\n' "${roothash}" > "${FLAGS_verity_hash}" + if [[ "${capable}" == "true" ]]; then + FLAGS_verity=1 + if failure="$(uki_provision_rpm "${esp}" 2>&1)"; then + echo "IPE-capable UKI accepted disabled /usr verity" >&2 + return 1 + fi + [[ "${failure}" == *"IPE assets require a /usr dm-verity root hash"* ]] + FLAGS_verity=0 + + mv "${BUILD_DIR}/acl-ipe-policy/acl-ipe-policy.p7b.cred" \ + "${BUILD_DIR}/policy.cred" + if failure="$(uki_provision_rpm "${esp}" 2>&1)"; then + echo "IPE-capable UKI accepted a missing policy credential" >&2 + return 1 + fi + [[ "${failure}" == *"staged IPE policy candidate not found"* ]] + mv "${BUILD_DIR}/policy.cred" \ + "${BUILD_DIR}/acl-ipe-policy/acl-ipe-policy.p7b.cred" + fi + uki_provision_rpm "${esp}" + cmdline="$(<"${BUILD_DIR}/${uki_name}.cmdline")" + [[ "${cmdline}" == *"mount.usr=/dev/mapper/usr mount.usrflags=ro"* ]] + [[ "${cmdline}" != *"usrhash="* && "${cmdline}" != *"systemd.verity_usr_"* ]] + [[ "${cmdline}" != *"acl.slot="* ]] + [[ "${cmdline}" != *"root-hash-signature="* ]] + for slot in a b; do + addon_cmdline="$(<"${BUILD_DIR}/slot-${slot}.addon.efi.cmdline")" + data_uuid="$(jq -r --arg label "USR-${slot^^}" \ + '.layouts.base[] | select(.label == $label) | .uuid' \ + "${BUILD_LIBRARY_DIR}/disk_layout_uki.json")" + hash_uuid="$(jq -r --arg label "HASH-${slot^^}" \ + '.layouts.base[] | select(.label == $label) | .uuid' \ + "${BUILD_LIBRARY_DIR}/disk_layout_uki.json")" + [[ "${addon_cmdline}" == "systemd.verity_usr_data=PARTUUID=${data_uuid} systemd.verity_usr_hash=PARTUUID=${hash_uuid} systemd.verity_usr_options=panic-on-corruption usrhash=${roothash} acl.slot=${slot}" ]] + cmp "${BUILD_DIR}/slot-${slot}.addon.efi.cmdline" \ + "${esp}/acl/uki-addons/slot-${slot}.addon.efi" + done + cmp "${esp}/acl/uki-addons/slot-a.addon.efi" "${extra}/slot-a.addon.efi" + [[ ! -e "${extra}/slot-b.addon.efi" ]] + if [[ "${capable}" == "true" ]]; then + policy_hash="$(sha256sum "${BUILD_DIR}/acl-ipe-policy/acl-ipe-policy.p7b.cred" | cut -d' ' -f1)" + [[ " ${cmdline} " == *" acl.ipe.policy_sha256=${policy_hash} "* ]] + cmp "${BUILD_DIR}/acl-ipe-policy/acl-ipe-policy.p7b.cred" \ + "${extra}/acl-ipe-policy.p7b.cred" + else + [[ "${cmdline}" != *"acl.ipe.policy_sha256="* ]] + [[ ! -e "${extra}/acl-ipe-policy.p7b.cred" ]] + fi + cmp "${BUILD_DIR}/${uki_name}.cmdline" "${esp}/EFI/Linux/${uki_name}" + done +) + +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}" +} + +write_test_uki() { + printf 'mock UKI\n' > "$1" + printf '%s\n' "$2" > "$1.cmdline" +} + +test_vm_conversions_share_secure_boot_cert() { + local cert_dir="${TEST_DIR}/vm-shared-cert" + local other_cert_dir="${TEST_DIR}/vm-other-cert" + local first_cert="${TEST_DIR}/vm-first-cert.pem" + local artifact_dir="${TEST_DIR}/vm-artifact" + local esp_dir="${TEST_DIR}/vm-esp" + local extra_dir="${esp_dir}/EFI/Linux/acl.efi.extra.d" + local uki="${esp_dir}/EFI/Linux/acl.efi" + local verifier="${SCRIPT_DIR}/build_library/rpm/verify_ipe_signer_continuity.sh" + + ukify() { + [[ "$1" == inspect && "$3" == --json=short && + "$4" == --section=.cmdline:text && "$(<"$2")" == "mock UKI" ]] || return 1 + if [[ -e "$2.invalid-json" ]]; then + printf '{\n' + elif [[ -e "$2.invalid-text" ]]; then + printf '{".cmdline":{"text":123}}\n' + elif [[ -e "$2.nul" ]]; then + printf '{".cmdline":{"text":"quiet\\u0000bad"}}\n' + elif [[ -r "$2.cmdline" ]]; then + jq -n --arg text "$(<"$2.cmdline")" '{".cmdline": {"text": $text}}' + else + printf '{}\n' + fi + } + export -f ukify + + "${SCRIPT_DIR}/build_library/rpm/ensure_ephemeral_cert.sh" "${cert_dir}" create >/dev/null 2>&1 + cp "${cert_dir}/uki-signing-ca.pem" "${first_cert}" + "${SCRIPT_DIR}/build_library/rpm/ensure_ephemeral_cert.sh" "${cert_dir}" require >/dev/null 2>&1 + cmp -s "${first_cert}" "${cert_dir}/uki-signing-ca.pem" + + mkdir -p "${artifact_dir}/acl-ipe-policy" "${extra_dir}" "${esp_dir}/loader" + printf 'default acl.efi\n' > "${esp_dir}/loader/loader.conf" + openssl smime -sign -binary \ + -in "${policy}" \ + -signer "${cert_dir}/uki-signing-ca.pem" \ + -inkey "${cert_dir}/ca.key" \ + -noattr -nodetach -nosmimecap \ + -outform der \ + -out "${artifact_dir}/acl-ipe-policy/acl-ipe-policy.p7b.cred" 2>/dev/null + cp "${artifact_dir}/acl-ipe-policy/acl-ipe-policy.p7b.cred" \ + "${extra_dir}/acl-ipe-policy.p7b.cred" + + local hash token + hash="$(sha256sum "${extra_dir}/acl-ipe-policy.p7b.cred" | cut -d' ' -f1)" + token="acl.ipe.policy_sha256=${hash}" + write_test_uki "${uki}" "quiet ${token}" + bash "${verifier}" "${cert_dir}" "${artifact_dir}" "${esp_dir}" + + local bad_cmdline + for bad_cmdline in "" quiet "acl.ipe.policy_sha256=bad" \ + "acl.ipe.policy_sha256=$(printf '%064d' 0)" "${token} ${token}"; do + write_test_uki "${uki}" "${bad_cmdline}" + if bash "${verifier}" "${cert_dir}" "${artifact_dir}" "${esp_dir}" 2>/dev/null; then + echo "missing, malformed, wrong or duplicate UKI policy hash was accepted" >&2 + return 1 + fi + done + write_test_uki "${uki}" "${token}" + + local addon="${extra_dir}/debug.addon.efi" + local global_dir="${esp_dir}/loader/addons" + write_test_uki "${addon}" "systemd.log_level=debug" + bash "${verifier}" "${cert_dir}" "${artifact_dir}" "${esp_dir}" + for bad_cmdline in "${token}" "quiet acl.ipe.policy_sha256=bad" \ + "acl.ipe.policy_sha256="; do + write_test_uki "${addon}" "${bad_cmdline}" + if bash "${verifier}" "${cert_dir}" "${artifact_dir}" "${esp_dir}" 2>/dev/null; then + echo "IPE policy hash in active debug addon was accepted" >&2 + return 1 + fi + done + rm "${addon}.cmdline" + bash "${verifier}" "${cert_dir}" "${artifact_dir}" "${esp_dir}" + : > "${addon}.invalid-json" + if bash "${verifier}" "${cert_dir}" "${artifact_dir}" "${esp_dir}" 2>/dev/null; then + echo "invalid addon inspection was accepted" >&2 + return 1 + fi + rm "${addon}.invalid-json" + : > "${addon}.invalid-text" + if bash "${verifier}" "${cert_dir}" "${artifact_dir}" "${esp_dir}" 2>/dev/null; then + echo "non-text addon command line was accepted" >&2 + return 1 + fi + rm "${addon}.invalid-text" + : > "${addon}.nul" + if bash "${verifier}" "${cert_dir}" "${artifact_dir}" "${esp_dir}" 2>/dev/null; then + echo "NUL in addon command line was accepted" >&2 + return 1 + fi + rm "${addon}.nul" + printf 'invalid UKI\n' > "${addon}" + if bash "${verifier}" "${cert_dir}" "${artifact_dir}" "${esp_dir}" 2>/dev/null; then + echo "unreadable active addon was accepted" >&2 + return 1 + fi + rm "${addon}" + + write_test_uki "${extra_dir}/custom.ADDON.EFI" "${token}" + if bash "${verifier}" "${cert_dir}" "${artifact_dir}" "${esp_dir}" 2>/dev/null; then + echo "IPE policy hash in another active addon was accepted" >&2 + return 1 + fi + rm "${extra_dir}/custom.ADDON.EFI"* + + mkdir -p "${global_dir}" "${esp_dir}/EFI/Linux/other.efi.extra.d" + write_test_uki "${global_dir}/global.addon.efi" "${token}" + if bash "${verifier}" "${cert_dir}" "${artifact_dir}" "${esp_dir}" 2>/dev/null; then + echo "IPE policy hash in global addon was accepted" >&2 + return 1 + fi + rm "${global_dir}/global.addon.efi"* + write_test_uki "${esp_dir}/EFI/Linux/other.efi.extra.d/other.addon.efi" "${token}" + bash "${verifier}" "${cert_dir}" "${artifact_dir}" "${esp_dir}" + rm -r "${esp_dir}/EFI/Linux/other.efi.extra.d" + + local loader_config + for loader_config in "" "default other.efi" "default *.efi" \ + $'default acl.efi\ndefault other.efi'; do + printf '%s\n' "${loader_config}" > "${esp_dir}/loader/loader.conf" + if bash "${verifier}" "${cert_dir}" "${artifact_dir}" "${esp_dir}" 2>/dev/null; then + echo "missing or ambiguous selected UKI was accepted" >&2 + return 1 + fi + done + printf 'default acl.efi\n' > "${esp_dir}/loader/loader.conf" + mv "${extra_dir}" "${esp_dir}/EFI/Linux/other.efi.extra.d" + if bash "${verifier}" "${cert_dir}" "${artifact_dir}" "${esp_dir}" 2>/dev/null; then + echo "another UKI's credential satisfied the selected UKI" >&2 + return 1 + fi + mv "${esp_dir}/EFI/Linux/other.efi.extra.d" "${extra_dir}" + + printf 'not a UKI\n' > "${uki}" + if bash "${verifier}" "${cert_dir}" "${artifact_dir}" "${esp_dir}" 2>/dev/null; then + echo "invalid UKI was accepted" >&2 + return 1 + fi + write_test_uki "${uki}" "${token}" + + local staged="${artifact_dir}/acl-ipe-policy/acl-ipe-policy.p7b.cred" + cp "${staged}" "${TEST_DIR}/original.cred" + { cat "${policy}"; printf '\n# alternate signed input\n'; } > "${TEST_DIR}/alternate.pol" + openssl smime -sign -binary -in "${TEST_DIR}/alternate.pol" \ + -signer "${cert_dir}/uki-signing-ca.pem" -inkey "${cert_dir}/ca.key" \ + -noattr -nodetach -nosmimecap -outform der \ + -out "${extra_dir}/acl-ipe-policy.p7b.cred" 2>/dev/null + if bash "${verifier}" "${cert_dir}" "${artifact_dir}" "${esp_dir}" 2>/dev/null; then + echo "different installed credential with the same signer was accepted" >&2 + return 1 + fi + cp "${extra_dir}/acl-ipe-policy.p7b.cred" "${staged}" + if bash "${verifier}" "${cert_dir}" "${artifact_dir}" "${esp_dir}" 2>/dev/null; then + echo "matching credentials not bound into the UKI were accepted" >&2 + return 1 + fi + cp "${TEST_DIR}/original.cred" "${staged}" + cp "${staged}" "${extra_dir}/acl-ipe-policy.p7b.cred" + + "${SCRIPT_DIR}/build_library/rpm/ensure_ephemeral_cert.sh" \ + "${other_cert_dir}" create >/dev/null 2>&1 + if bash "${verifier}" \ + "${other_cert_dir}" "${artifact_dir}" "${esp_dir}" 2>/dev/null; then + echo "matching but unrelated signer pair was accepted" >&2 + return 1 + fi + printf 'invalid policy credential\n' > "${extra_dir}/acl-ipe-policy.p7b.cred" + if bash "${verifier}" \ + "${cert_dir}" "${artifact_dir}" "${esp_dir}" 2>/dev/null; then + echo "invalid installed policy credential was accepted" >&2 + return 1 + fi + unset -f ukify +} + +test_disabled_vm_signing_is_scoped_to_build() ( + source_test_functions "${SCRIPT_DIR}/image_to_vm.sh" get_disabled_vm_signing_dir + unset ACL_EPHEMERAL_CERT_DIR + + local board_dir="${TEST_DIR}/disabled signer/amd64-usr" + local first_output="${board_dir}/build-1" second_output="${board_dir}/build-2" + local first_dir second_dir + local first_key="${TEST_DIR}/disabled-first-key" + local ensure_cert="${SCRIPT_DIR}/build_library/rpm/ensure_ephemeral_cert.sh" + mkdir -p "${first_output}" "${second_output}" + + first_dir="$(get_disabled_vm_signing_dir "${first_output}")" + second_dir="$(get_disabled_vm_signing_dir "${second_output}")" + [[ "${first_dir}" == "${board_dir}/.acl-secureboot-signing/build-1" ]] + [[ "${second_dir}" == "${board_dir}/.acl-secureboot-signing/build-2" ]] + [[ "${first_dir}" != "${first_output}/"* && "${second_dir}" != "${second_output}/"* ]] + + "${ensure_cert}" "${first_dir}" create >/dev/null 2>&1 + cp "${first_dir}/ca.key" "${first_key}" + "${ensure_cert}" "$(get_disabled_vm_signing_dir "${first_output}")" create >/dev/null 2>&1 + cmp -s "${first_key}" "${first_dir}/ca.key" + + "${ensure_cert}" "${second_dir}" create >/dev/null 2>&1 + if cmp -s "${first_dir}/ca.key" "${second_dir}/ca.key"; then + echo "unrelated disabled builds reused the same private key" >&2 + return 1 + fi + + ACL_EPHEMERAL_CERT_DIR="" + [[ "$(get_disabled_vm_signing_dir "${first_output}")" == "${first_dir}" ]] + ACL_EPHEMERAL_CERT_DIR="${TEST_DIR}/explicit signer" + [[ "$(get_disabled_vm_signing_dir "${first_output}")" == "${ACL_EPHEMERAL_CERT_DIR}" ]] + [[ "$(get_disabled_vm_signing_dir "${second_output}")" == "${ACL_EPHEMERAL_CERT_DIR}" ]] +) + +test_markerless_ipe_assets_are_rejected() { + local image_to_vm="${SCRIPT_DIR}/image_to_vm.sh" + local artifact_dir="${TEST_DIR}/markerless-artifact" + local esp_dir="${TEST_DIR}/markerless-esp" + + source_test_functions "${image_to_vm}" \ + has_ipe_assets validate_ipe_marker_consistency + + mkdir -p "${artifact_dir}/acl-ipe-policy" "${esp_dir}/EFI/Linux/acl.efi.extra.d" + : > "${artifact_dir}/acl-ipe-policy/acl-ipe-policy.p7b.cred" + + if validate_ipe_marker_consistency "${artifact_dir}" "${esp_dir}" 2>/dev/null; then + echo "markerless IPE assets bypassed conversion validation" >&2 + return 1 + fi + + printf 'ephemeral\n' > "${artifact_dir}/ipe-signing-mode" + validate_ipe_marker_consistency "${artifact_dir}" "${esp_dir}" +} + +test_incomplete_or_mismatched_cert_pair_rejected() { + local cert_dir="${TEST_DIR}/incomplete-cert" + local empty_cert_dir="${TEST_DIR}/empty-cert" + local other_cert_dir="${TEST_DIR}/mismatched-cert" + local original_cert="${TEST_DIR}/incomplete-original.pem" + + mkdir -p "${empty_cert_dir}" + "${SCRIPT_DIR}/build_library/rpm/ensure_ephemeral_cert.sh" \ + "${empty_cert_dir}" create >/dev/null 2>&1 + [[ -s "${empty_cert_dir}/ca.key" ]] + [[ -s "${empty_cert_dir}/uki-signing-ca.pem" ]] + + "${SCRIPT_DIR}/build_library/rpm/ensure_ephemeral_cert.sh" \ + "${cert_dir}" create >/dev/null 2>&1 + cp "${cert_dir}/uki-signing-ca.pem" "${original_cert}" + rm "${cert_dir}/ca.key" + if "${SCRIPT_DIR}/build_library/rpm/ensure_ephemeral_cert.sh" \ + "${cert_dir}" require 2>/dev/null; then + echo "incomplete certificate pair was accepted" >&2 + return 1 + fi + if "${SCRIPT_DIR}/build_library/rpm/ensure_ephemeral_cert.sh" \ + "${cert_dir}" create 2>/dev/null; then + echo "incomplete certificate pair was silently rotated" >&2 + return 1 + fi + cmp -s "${original_cert}" "${cert_dir}/uki-signing-ca.pem" + + rm -rf "${cert_dir}" + "${SCRIPT_DIR}/build_library/rpm/ensure_ephemeral_cert.sh" \ + "${cert_dir}" create >/dev/null 2>&1 + "${SCRIPT_DIR}/build_library/rpm/ensure_ephemeral_cert.sh" \ + "${other_cert_dir}" create >/dev/null 2>&1 + cp "${other_cert_dir}/ca.key" "${cert_dir}/ca.key" + if "${SCRIPT_DIR}/build_library/rpm/ensure_ephemeral_cert.sh" \ + "${cert_dir}" require 2>/dev/null; then + echo "mismatched certificate pair was accepted" >&2 + return 1 + fi +} + +test_markerless_secure_boot_cert_remains_disabled() { + local build_script="${SCRIPT_DIR}/acl/build_rpm_image.sh" + source_test_functions "${build_script}" load_artifact_ipe_signing_mode + 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_pure_vm_reuse_skips_local_artifact() { + local build_script="${SCRIPT_DIR}/acl/build_rpm_image.sh" + source_test_functions "${build_script}" \ + operation_uses_vm_image operation_uses_gallery_image \ + operation_uses_local_image_artifact + + BUILD_IMAGE=false + BUILD_VM_IMAGE=false + BUILD_TEST_IMAGE=false + START_VM=true + RUN_KOLA_TESTS=false + REUSE_VM=true + REUSE_IMAGE=false + ACG_IMAGE_VERSION_ID="" + + if operation_uses_local_image_artifact; then + echo "pure VM reuse was classified as local-artifact consumption" >&2 + return 1 + fi + + BUILD_VM_IMAGE=true + operation_uses_local_image_artifact +} + +test_reused_vm_type_loaded_from_state() { + local build_script="${SCRIPT_DIR}/acl/build_rpm_image.sh" + local state_file="${TEST_DIR}/vm-state.env" + source_test_functions "${build_script}" load_reused_vm_type + error() { echo "$*" >&2; } + + REUSE_VM=true + VM_TYPE=qemu + printf 'VM_TYPE=azure\n' > "${state_file}" + load_reused_vm_type "${state_file}" + [[ "${VM_TYPE}" == "azure" ]] + + printf 'VM_TYPE=invalid\n' > "${state_file}" + if load_reused_vm_type "${state_file}" 2>/dev/null; then + echo "invalid reused VM type was 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_uki_provision_preserves_unsigned_verity +test_uki_binds_policy_before_writing_cmdline +test_vm_conversions_share_secure_boot_cert +test_disabled_vm_signing_is_scoped_to_build +test_markerless_ipe_assets_are_rejected +test_incomplete_or_mismatched_cert_pair_rejected +test_markerless_secure_boot_cert_remains_disabled +test_pure_vm_reuse_skips_local_artifact +test_reused_vm_type_loaded_from_state + +echo "IPE policy input tests passed" diff --git a/acl/tests/ipe/offline/test-node-security-profile.sh b/acl/tests/ipe/offline/test-node-security-profile.sh new file mode 100755 index 00000000000..fd19ff87651 --- /dev/null +++ b/acl/tests/ipe/offline/test-node-security-profile.sh @@ -0,0 +1,161 @@ +#!/bin/bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)" +TEST_DIR="$(mktemp -d)" +trap 'rm -rf "${TEST_DIR}"' EXIT + +source "${SCRIPT_DIR}/build_library/rpm/additional_files/acl-node-security-profile.sh" + +systemctl() { :; } +sleep() { :; } + +MOCK_CURL_BODY='[]' +MOCK_CURL_STATUS=0 +MOCK_JQ_PARTIAL=false +CURL_CALLS_FILE="${TEST_DIR}/curl-calls" + +acl_usrbin() { + local cmd="$1" + shift + + case "${cmd}" in + curl) + printf 'x\n' >> "${CURL_CALLS_FILE}" + printf '%s' "${MOCK_CURL_BODY}" + return "${MOCK_CURL_STATUS}" + ;; + jq) + if [[ "${MOCK_JQ_PARTIAL}" == "true" ]]; then + printf '%s' 'selinux=permissive' + return 4 + fi + command jq "$@" + ;; + *) + return 127 + ;; + esac +} + +prepare_case() { + local name="$1" + + ACL_SECURITY_PROFILE_CACHE="${TEST_DIR}/${name}/node-security-profile" + ACL_SECURITY_PROFILE_FAILURE_CACHE="${ACL_SECURITY_PROFILE_CACHE}.failed" + mkdir -p "${ACL_SECURITY_PROFILE_CACHE%/*}" + MOCK_CURL_BODY='[]' + MOCK_CURL_STATUS=0 + MOCK_JQ_PARTIAL=false + : > "${CURL_CALLS_FILE}" +} + +curl_calls() { + wc -l < "${CURL_CALLS_FILE}" | tr -d '[:space:]' +} + +assert_parse_fails() { + local input="$1" + + if printf '%s' "${input}" | acl_security_profile_parse >/dev/null 2>&1; then + echo "invalid IMDS response was accepted: ${input}" >&2 + return 1 + fi +} + +test_parser_contract() { + [[ "$(printf '%s' '[]' | acl_security_profile_parse)" == "" ]] + [[ "$( + printf '%s' '[{"name":"acl-node-security-profile","value":"ipe=audit"}]' | + acl_security_profile_parse + )" == "ipe=audit" ]] + [[ "$( + printf '%s' '[{"name":"acl-node-security-profile","value":""}]' | + acl_security_profile_parse + )" == "" ]] + + assert_parse_fails '{}' + assert_parse_fails '[{"name":"acl-node-security-profile","value":null}]' + assert_parse_fails '[{"name":"acl-node-security-profile","value":"ipe=audit"},{"name":"acl-node-security-profile","value":"ipe=disabled"}]' + assert_parse_fails '[{"name":"acl-node-security-profile","value":"ipe=audit"}' + assert_parse_fails '[{"name":"acl-node-security-profile","value":"ipe=audit"}] []' +} + +test_valid_response_is_cached() { + prepare_case valid + MOCK_CURL_BODY='[{"name":"acl-node-security-profile","value":"ipe=audit"}]' + + [[ "$(acl_security_profile)" == "ipe=audit" ]] + [[ "$(<"${ACL_SECURITY_PROFILE_CACHE}")" == "ipe=audit" ]] + [[ ! -e "${ACL_SECURITY_PROFILE_FAILURE_CACHE}" ]] + [[ "$(curl_calls)" -eq 1 ]] + + MOCK_CURL_STATUS=1 + [[ "$(acl_security_profile)" == "ipe=audit" ]] + [[ "$(curl_calls)" -eq 1 ]] +} + +test_absent_tag_is_successfully_cached() { + prepare_case absent + + [[ "$(acl_security_profile)" == "" ]] + [[ -f "${ACL_SECURITY_PROFILE_CACHE}" ]] + [[ ! -s "${ACL_SECURITY_PROFILE_CACHE}" ]] + [[ ! -e "${ACL_SECURITY_PROFILE_FAILURE_CACHE}" ]] +} + +test_parser_failure_never_publishes_positive_cache() { + prepare_case parser-failure + MOCK_CURL_BODY='[{"name":"acl-node-security-profile","value":"selinux=permissive"}]' + MOCK_JQ_PARTIAL=true + + if acl_security_profile >/dev/null 2>&1; then + echo "partial parser output was accepted" >&2 + return 1 + fi + [[ ! -e "${ACL_SECURITY_PROFILE_CACHE}" ]] + [[ -e "${ACL_SECURITY_PROFILE_FAILURE_CACHE}" ]] + + MOCK_JQ_PARTIAL=false + MOCK_CURL_BODY='[{"name":"acl-node-security-profile","value":"ipe=audit"}]' + if acl_security_profile >/dev/null 2>&1; then + echo "cached parser failure was ignored" >&2 + return 1 + fi +} + +test_multiple_documents_never_publish_positive_cache() { + prepare_case multiple-documents + MOCK_CURL_BODY='[{"name":"acl-node-security-profile","value":"ipe=audit"}] []' + + if acl_security_profile >/dev/null 2>&1; then + echo "multiple JSON documents were accepted" >&2 + return 1 + fi + [[ ! -e "${ACL_SECURITY_PROFILE_CACHE}" ]] + [[ -e "${ACL_SECURITY_PROFILE_FAILURE_CACHE}" ]] +} + +test_transport_failure_never_publishes_positive_cache() { + prepare_case transport-failure + MOCK_CURL_BODY='partial' + MOCK_CURL_STATUS=1 + + if acl_security_profile >/dev/null 2>&1; then + echo "failed IMDS transport was accepted" >&2 + return 1 + fi + [[ "$(curl_calls)" -eq 30 ]] + [[ ! -e "${ACL_SECURITY_PROFILE_CACHE}" ]] + [[ -e "${ACL_SECURITY_PROFILE_FAILURE_CACHE}" ]] +} + +test_parser_contract +test_valid_response_is_cached +test_absent_tag_is_successfully_cached +test_parser_failure_never_publishes_positive_cache +test_multiple_documents_never_publish_positive_cache +test_transport_failure_never_publishes_positive_cache + +echo "node security profile 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..f4cbadf244a --- /dev/null +++ b/acl/tests/ipe/offline/test-run-sdk-container-ipe-policy.sh @@ -0,0 +1,103 @@ +#!/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_mode_forwarded +test_invalid_mode_rejected +test_invalid_signing_mode_rejected +test_enforcing_mode_rejected + +echo "run_sdk_container IPE mode tests passed" diff --git a/acl/tests/ipe/run-ipe-audit-test.sh b/acl/tests/ipe/run-ipe-audit-test.sh new file mode 100755 index 00000000000..65189601f7e --- /dev/null +++ b/acl/tests/ipe/run-ipe-audit-test.sh @@ -0,0 +1,185 @@ +#!/bin/bash +# Live-VM validation of policy-only IPE audit mode. + +set -euo pipefail + +ipe_cmdline_field() { + local input="$1" name="$2" delimiter="${3:- }" field + local -a fields=() + + IFS="${delimiter}" read -r -a fields <<< "${input}" + for field in "${fields[@]}"; do + if [[ "${field}" == "${name}="* ]]; then + printf '%s\n' "${field#*=}" + return 0 + fi + done + printf '\n' +} + +ipe_has_root_hash_signature() { + local word option + local -a options=() + + for word in $1; do + case "${word}" in + root-hash-signature=*) return 0 ;; + systemd.verity_usr_options=*) + IFS=, read -r -a options <<< "${word#*=}" + for option in "${options[@]}"; do + [[ "${option}" != root-hash-signature=* ]] || return 0 + done + ;; + esac + done + return 1 +} + +ipe_find_denial_event() { + local logs="$1" executable="$2" pid="$3" + [[ "${pid}" =~ ^[1-9][0-9]*$ ]] || { + echo "Invalid IPE probe PID: ${pid}" >&2 + return 1 + } + grep -F "path=\"${executable}\"" <<< "${logs}" | + grep -E "(^|[[:space:]])pid=${pid}([[:space:]]|$)" | + grep -E 'ipe_op=EXECUTE([[:space:]]|$)' | + grep -E 'enforcing=0([[:space:]]|$)' | + grep -E 'rule="[^"]*action=DENY"' +} + +# Sourcing exposes the helpers to host tests without running guest assertions. +# Stdin scripts have no BASH_SOURCE path, so they still execute on the guest. +if [[ -n "${BASH_SOURCE[0]:-}" && "${BASH_SOURCE[0]}" != "${0}" ]]; then + return 0 +fi + +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 Audit 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="$(ipe_cmdline_field "${cmdline}" usrhash)" +usr_hash="${usr_hash,,}" +if ! [[ "${usr_hash}" =~ ^[[:xdigit:]]{64}$ ]]; then + fail "could not read the /usr dm-verity SHA-256 root hash from the command line" +fi +if ipe_has_root_hash_signature "${cmdline}"; then + fail "policy-only IPE must not require a /usr root-hash signature" +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 audit 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 + +verity_name="$( + dmsetup info --columns --noheadings --options name "${verity_device}" 2>/dev/null | + xargs +)" +if [[ -z "${verity_name}" ]]; then + fail "could not resolve the active /usr dm-verity mapping" +fi +verity_table="$(dmsetup table --showkeys "${verity_name}" 2>/dev/null)" || + fail "could not read the active /usr dm-verity table" +if ! grep -Eq "(^|[[:space:]])${usr_hash}([[:space:]]|$)" <<< "${verity_table}"; then + fail "active /usr dm-verity table does not contain the UKI root hash" +fi +if grep -Eq '(^|[[:space:]])root_hash_sig_key_desc([[:space:]]|$)' \ + <<< "${verity_table}"; then + fail "policy-only /usr dm-verity mapping unexpectedly requires a root-hash signature" +fi + +# Neither unsigned /usr nor writable storage matches the signature allow rule. +# Record each exec PID so older audit events cannot satisfy these probes. +usr_probe="$(readlink -f /usr/bin/true)" || fail "could not resolve /usr probe" +[[ "${usr_probe}" == /usr/* ]] || fail "probe executable is not on /usr" +usr_probe_pid="$(bash -c 'printf "%s\n" "$$"; exec "$1"' -- "${usr_probe}")" || + fail "/usr execution was blocked in audit mode" +probe="$(mktemp /var/tmp/acl-ipe-audit-probe.XXXXXX)" || + fail "could not create writable-storage probe" +trap 'rm -f "${probe}"' EXIT +cp "${usr_probe}" "${probe}" +chmod 0755 "${probe}" +probe_pid="$(bash -c 'printf "%s\n" "$$"; exec "$1"' -- "${probe}")" || + fail "writable-storage execution was blocked in audit 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 +probes=("${usr_probe}" "${probe}") +probe_pids=("${usr_probe_pid}" "${probe_pid}") +for index in "${!probes[@]}"; do + audit_event="$(ipe_find_denial_event \ + "${boot_logs}" "${probes[index]}" "${probe_pids[index]}")" || + fail "${probes[index]} (pid=${probe_pids[index]}) succeeded without its expected IPE denial audit event" + echo "Observed IPE audit event for ${probes[index]}:" + echo "${audit_event}" +done + +echo "" +echo "IPE policy: ${POLICY_NAME}" +echo "IPE enforce state: 0 (audit)" +echo "/usr dm-verity root hash: ${usr_hash}" +echo "/usr dm-verity mapping: ${verity_name} (no root-hash signature required)" +echo "SUCCESS: IPE is active in audit mode with no detected boot errors" 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..f949b288d8c --- /dev/null +++ b/acl/tests/ipe/run-ipe-mode-toggle-test.sh @@ -0,0 +1,225 @@ +#!/bin/bash +# Test reboot-based IPE mode selection through acl-node-security-profile. +# +# Canonical acl-node-security-profile values are 'disabled' and 'audit'; +# 'off' is also accepted as a runtime alias for 'disabled'. +# '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 +# exercises the 'off' alias. + +set -euo pipefail + +source "${SCRIPT_DIR}/acl/validate/validate_common.sh" +source "${SCRIPT_DIR}/acl/tests/azure-security-profile-test-common.sh" +source "${SCRIPT_DIR}/acl/tests/ipe/run-ipe-audit-test.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 "audit" ;; + 1) echo "enforcing" ;; + *) echo "unknown:${enforce}" ;; + esac +} + +# Maps an acl-node-security-profile 'ipe' value (canonical or supported 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 -> active/audit, +# enforcing (reserved) and anything unrecognized -> inactive. +expected_mode_for_tag() { + case "$1" in + disabled|off|"") echo "off" ;; + audit) echo "audit" ;; + *) 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 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="$(ipe_cmdline_field "${cmdline}" acl.ipe.policy_sha256)" + 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="$(ipe_cmdline_field "${cmdline}" usrhash)" + usr_hash="${usr_hash,,}" + if ! [[ "${usr_hash}" =~ ^[[:xdigit:]]{64}$ ]]; then + error "Valid /usr root hash is missing from the UKI" + return 1 + fi + if ipe_has_root_hash_signature "${cmdline}"; then + error "Policy-only IPE must not require a /usr root-hash signature" + return 1 + fi + info "IPE assets are present and policy active state is '${policy_active}'" +} + +run_audit_validation() { + ssh "${SSH_OPTS[@]}" "${VM_SSH_USER}@${VM_IP}" \ + "sudo bash -s" \ + < "${SCRIPT_DIR}/acl/tests/ipe/run-ipe-audit-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 + capture_security_profile_state || exit 1 + trap restore_security_profile_on_exit EXIT + + 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 + initial_profile="$(imds_security_profile 2>/dev/null)" || { + error "Could not read initial acl-node-security-profile from IMDS" + exit 1 + } + 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}" == "audit" ]]; then + assert_ipe_assets_present 1 + run_audit_validation + else + assert_ipe_assets_present 0 + fi + + section "Step 1: Disable IPE through the canonical 'disabled' value" + set_security_profile_key_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_key_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_key_and_reboot "ipe" "audit" + assert_ipe_mode "audit" + assert_ipe_assets_present 1 + run_audit_validation + + section "Step 4: Disable IPE through the canonical 'disabled' value" + set_security_profile_key_and_reboot "ipe" "disabled" + assert_ipe_mode "off" + assert_ipe_assets_present + + section "Step 5: Verify the 'off' alias" + set_security_profile_key_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/run-selinux-toggle-test.sh b/acl/tests/run-selinux-toggle-test.sh index 5866409fd02..91bf1c992d1 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 ─────────────────────────────────────────────────────────── @@ -146,6 +46,8 @@ main() { error "Cannot reach VM via SSH" exit 1 fi + capture_security_profile_state || exit 1 + trap restore_security_profile_on_exit EXIT local exit_code=0 @@ -160,29 +62,15 @@ main() { warn "The test will still toggle and verify, but the baseline is unexpected." fi - # Step 2: Toggle to permissive (with extra k/v to exercise comma-separated parsing). + # Step 2: Toggle to permissive while preserving unrelated profile keys. 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_key_and_reboot "selinux" "permissive" + 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 - - # 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_key_and_reboot "selinux" "enforcing" + 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/validate/test_validate_azure.sh b/acl/validate/test_validate_azure.sh index f3aa4f2cae2..79233eb27f1 100755 --- a/acl/validate/test_validate_azure.sh +++ b/acl/validate/test_validate_azure.sh @@ -139,6 +139,395 @@ assert_arm_warning_does_not_corrupt_vm_result() { printf 'PASS: ARM security warnings do not corrupt VM classification\n' } +assert_ipe_contract_rejects_before_az() { + local az_calls="${TEST_TMPDIR}/ipe-override-az-calls" rc=0 + az() { + printf 'called\n' >> "${az_calls}" + return 0 + } + + BOARD=amd64-usr + ACL_IPE_CAPABLE=true + SECURE_BOOT_ENABLED=true + local option + local -a overrides=(-iother) + for option in -i --i --im --ima --imag --image; do + overrides+=("${option} other" "${option}=other") + done + for AZ_VM_ARGS in "${overrides[@]}"; do + rc=0 + if _try_vm_create test-rg test-vm test-image test-sku test-region >/dev/null 2>&1; then + rc=0 + else + rc=$? + fi + [[ ${rc} -eq 2 && ! -e "${az_calls}" ]] || { + printf 'FAIL: IPE override %q was not rejected before az vm create: rc=%s\n' \ + "${AZ_VM_ARGS}" "${rc}" >&2 + return 1 + } + done + + ACL_IPE_CAPABLE=false + AZ_VM_ARGS="-iother" + _enforce_ipe_image_contract + unset ACL_IPE_CAPABLE + _enforce_ipe_image_contract + printf 'PASS: IPE image contract rejects overrides before Azure CLI\n' +} + +assert_ipe_vm_argv_is_canonical() { + warn() { :; } + local args_file="${TEST_TMPDIR}/ipe-vm-create-args" + az() { + if [[ "$1 $2" != "vm create" ]]; then + printf 'unexpected Azure CLI command: %s\n' "$*" >&2 + return 1 + fi + printf '%s\n' "$@" > "${args_file}" + printf '{"id":"test-vm"}\n' + } + + local capability board secure_boot expected_secure_boot + export AZURE_TRUSTED_LAUNCH=false + AZ_VM_ARGS="--user-data config.ign --imds-mode Enforced" + for capability in false true; do + for board in amd64-usr arm64-usr; do + for secure_boot in false true; do + ACL_IPE_CAPABLE="${capability}" + BOARD="${board}" + SECURE_BOOT_ENABLED="${secure_boot}" + expected_secure_boot="${secure_boot}" + [[ "${board}" != arm64-usr ]] || expected_secure_boot=true + _try_vm_create test-rg test-vm test-image test-sku test-region >/dev/null + + [[ "$(grep -cx -- '--security-type' "${args_file}")" -eq 1 ]] + [[ "$(grep -cx -- '--enable-vtpm' "${args_file}")" -eq 1 ]] + [[ "$(grep -cx -- '--enable-secure-boot' "${args_file}")" -eq 1 ]] + grep -Fxq -- 'TrustedLaunch' "${args_file}" + [[ "$(grep -A1 -x -- '--enable-vtpm' "${args_file}" | tail -1)" == true ]] + [[ "$(grep -A1 -x -- '--enable-secure-boot' "${args_file}" | tail -1)" == "${expected_secure_boot}" ]] + grep -Fxq -- '--user-data' "${args_file}" + grep -Fxq -- 'config.ign' "${args_file}" + grep -Fxq -- '--imds-mode' "${args_file}" + done + done + done + + BOARD=amd64-usr + ACL_IPE_CAPABLE=false + AZ_VM_ARGS="--im=other" + _try_vm_create test-rg test-vm test-image test-sku test-region >/dev/null + grep -Fxq -- '--im=other' "${args_file}" + + printf 'PASS: smoke launch defaults are independent of IPE capability\n' +} + +assert_local_ipe_artifact_contract() { + warn() { :; } + local artifact_dir="${TEST_TMPDIR}/ipe-local-artifact" + local image="${artifact_dir}/acl_production_azure_test_image.vhd" + mkdir -p "${artifact_dir}" + printf 'vhd\n' > "${image}" + printf 'ephemeral\n' > "${artifact_dir}/ipe-signing-mode" + local subject='/CN=validate azure test/' + [[ -z "${MSYSTEM:-}" ]] || subject='//CN=validate azure test' + openssl req -x509 -newkey rsa:2048 -nodes \ + -subj "${subject}" \ + -keyout "${artifact_dir}/ca.key" \ + -out "${artifact_dir}/uki-signing-ca.pem" \ + -days 1 >/dev/null 2>&1 + + ACL_IPE_CAPABLE="" + BOARD=amd64-usr + SECURE_BOOT_ENABLED=true + AZ_VM_ARGS="" + _prepare_local_ipe_artifact_contract "${image}" + local canonical_artifact_dir + canonical_artifact_dir="$(cd -P "${artifact_dir}" && pwd -P)" + [[ "${ACL_IPE_CAPABLE}" == "true" ]] + [[ "${_LOCAL_IPE_SIGNING_MODE}" == "ephemeral" ]] + [[ "${_LOCAL_IPE_SIGNING_CERT}" == "${canonical_artifact_dir}/uki-signing-ca.pem" ]] + [[ "${_LOCAL_IPE_ARTIFACT_PATH}" == "${canonical_artifact_dir}/acl_production_azure_test_image.vhd" ]] + local expected_cert_b64 + expected_cert_b64="$( + openssl x509 -in "${artifact_dir}/uki-signing-ca.pem" -outform DER | + base64 | + tr -d '\r\n' + )" + [[ "${_LOCAL_IPE_SIGNING_CERT_B64}" == "${expected_cert_b64}" ]] + + SECURE_BOOT_ENABLED=false + _prepare_local_ipe_artifact_contract "${image}" + [[ "${_LOCAL_IPE_SIGNING_CERT_B64}" == "${expected_cert_b64}" ]] + printf 'not a certificate\n' > "${artifact_dir}/uki-signing-ca.pem" + if _prepare_local_ipe_artifact_contract "${image}" >/dev/null 2>&1; then + printf 'FAIL: invalid supplied certificate was accepted with Secure Boot off\n' >&2 + return 1 + fi + + SECURE_BOOT_ENABLED=true + rm -f "${artifact_dir}/uki-signing-ca.pem" + if _prepare_local_ipe_artifact_contract "${image}" >/dev/null 2>&1; then + printf 'FAIL: IPE local artifact without certificate was accepted\n' >&2 + return 1 + fi + + SECURE_BOOT_ENABLED=false + _prepare_local_ipe_artifact_contract "${image}" + [[ "${ACL_IPE_CAPABLE}" == true ]] + [[ -z "${_LOCAL_IPE_SIGNING_CERT}" && -z "${_LOCAL_IPE_SIGNING_CERT_B64}" ]] + + BOARD=arm64-usr + if _prepare_local_ipe_artifact_contract "${image}" >/dev/null 2>&1; then + printf 'FAIL: ARM Secure Boot launch accepted a missing certificate\n' >&2 + return 1 + fi + + printf 'PASS: local IPE artifact contract is fail-closed\n' +} + +assert_non_ipe_local_artifact_compatibility() { + warn() { :; } + local artifact_dir="${TEST_TMPDIR}/non-ipe-local-artifact" + local image="${artifact_dir}/image.vhd" + local certificate="${artifact_dir}/uki-signing-ca.pem" + local capability secure_boot signing_mode + mkdir -p "${artifact_dir}" + printf 'vhd\n' > "${image}" + printf 'not a certificate\n' > "${certificate}" + BOARD=amd64-usr + AZ_VM_ARGS="" + + for capability in "" false; do + for secure_boot in true false; do + ACL_IPE_CAPABLE="${capability}" + SECURE_BOOT_ENABLED="${secure_boot}" + _LOCAL_IPE_SIGNING_CERT=stale + _LOCAL_IPE_SIGNING_CERT_B64=stale + _prepare_local_ipe_artifact_contract "${image}" + [[ "${ACL_IPE_CAPABLE}" == false && "${_LOCAL_IPE_SIGNING_MODE}" == disabled ]] + [[ -z "${_LOCAL_IPE_SIGNING_CERT}" && -z "${_LOCAL_IPE_SIGNING_CERT_B64}" ]] + done + done + + ACL_IPE_CAPABLE=true + if _prepare_local_ipe_artifact_contract "${image}" >/dev/null 2>&1; then + echo "FAIL: expected IPE capability accepted missing metadata" >&2 + return 1 + fi + for signing_mode in ephemeral esrp; do + printf '%s\n' "${signing_mode}" > "${artifact_dir}/ipe-signing-mode" + ACL_IPE_CAPABLE=false + if _prepare_local_ipe_artifact_contract "${image}" >/dev/null 2>&1; then + echo "FAIL: explicit disabled capability bypassed IPE metadata" >&2 + return 1 + fi + done + rm "${artifact_dir}/ipe-signing-mode" + + mv "${image}" "${image}.target" + mv "${certificate}" "${certificate}.target" + if MSYS=winsymlinks:nativestrict ln -s "${image}.target" "${image}" 2>/dev/null && + MSYS=winsymlinks:nativestrict ln -s "${certificate}.target" "${certificate}" 2>/dev/null && + [[ -L "${image}" && -L "${certificate}" ]]; then + for secure_boot in true false; do + ACL_IPE_CAPABLE=false + SECURE_BOOT_ENABLED="${secure_boot}" + _prepare_local_ipe_artifact_contract "${image}" + done + for signing_mode in ephemeral esrp; do + printf '%s\n' "${signing_mode}" > "${artifact_dir}/ipe-signing-mode" + ACL_IPE_CAPABLE=true + if _prepare_local_ipe_artifact_contract "${image}" >/dev/null 2>&1; then + echo "FAIL: IPE accepted a symlinked VHD" >&2 + return 1 + fi + done + rm "${image}" + cp "${image}.target" "${image}" + local subject='/CN=symlink certificate test/' + [[ -z "${MSYSTEM:-}" ]] || subject='//CN=symlink certificate test' + openssl req -x509 -newkey rsa:2048 -nodes \ + -subj "${subject}" -days 1 \ + -keyout "${artifact_dir}/ca.key" \ + -out "${certificate}.target" >/dev/null 2>&1 + ipe_validate_signing_certificate "${certificate}.target" + if _prepare_local_ipe_artifact_contract "${image}" >/dev/null 2>&1; then + echo "FAIL: IPE accepted a symlinked certificate" >&2 + return 1 + fi + else + echo "SKIP: local VHD/certificate symlink checks (native symlinks unavailable)" + fi + + printf 'PASS: non-IPE preflight preserves inputs without bypassing IPE metadata\n' +} + +assert_gallery_certificate_compatibility() { + info() { :; } + warn() { :; } + error() { echo "$*" >&2; } + local artifact_dir="${TEST_TMPDIR}/gallery-certificate-compatibility" + local request="${artifact_dir}/request.json" route="${artifact_dir}/route" + local secure_boot signing_mode + mkdir -p "${artifact_dir}" + _VM_IMAGE_DIR="${artifact_dir}" + export AZ_VM_IMAGE_DEF=test-image + AZ_REGION=eastus2 + + az() { + case "$1 $2" in + "account show") echo test-subscription ;; + "storage account"|"sig show") echo eastus2 ;; + "rest --method") + if [[ "$3" == GET ]]; then + echo Succeeded + elif [[ "$3" == PUT ]]; then + echo rest > "${route}" + while [[ "$1" != --body ]]; do shift; done + printf '%s\n' "$2" > "${request}" + else + echo "Unexpected REST method: $3" >&2 + return 1 + fi + ;; + "sig image-version") echo cli > "${route}" ;; + *) echo "Unexpected Azure command: $*" >&2; return 1 ;; + esac + } + + for secure_boot in true false; do + SECURE_BOOT_ENABLED="${secure_boot}" + _LOCAL_IPE_SIGNING_MODE=disabled + _LOCAL_IPE_SIGNING_CERT=stale + _LOCAL_IPE_SIGNING_CERT_B64=stale + rm -f "${artifact_dir}/uki-signing-ca.pem" + create_gallery_image_version 1.0.0 test.vhd + [[ "$(<"${route}")" == cli ]] + + # Preserve the old PEM transport; Azure, not IPE preflight, validates it. + printf '%s\n' '-----BEGIN CERTIFICATE-----' 'not-a-certificate' \ + '-----END CERTIFICATE-----' > "${artifact_dir}/uki-signing-ca.pem" + create_gallery_image_version 1.0.0 test.vhd + [[ "$(<"${route}")" == rest ]] + [[ "$(jq -r '.properties.securityProfile.uefiSettings.additionalSignatures.db[0].value[0]' "${request}")" == not-a-certificate ]] + + for signing_mode in ephemeral esrp; do + _LOCAL_IPE_SIGNING_MODE="${signing_mode}" + _LOCAL_IPE_SIGNING_CERT="${artifact_dir}/uki-signing-ca.pem" + _LOCAL_IPE_SIGNING_CERT_B64=captured-certificate + create_gallery_image_version 1.0.0 test.vhd + [[ "$(<"${route}")" == rest ]] + [[ "$(jq -r '.properties.securityProfile.uefiSettings.additionalSignatures.db[0].value[0]' "${request}")" == captured-certificate ]] + done + done + SECURE_BOOT_ENABLED=true + _LOCAL_IPE_SIGNING_CERT_B64="" + if (create_gallery_image_version 1.0.0 test.vhd) >/dev/null 2>&1; then + echo "FAIL: IPE publication accepted a missing required certificate" >&2 + return 1 + fi + mv "${artifact_dir}/uki-signing-ca.pem" "${artifact_dir}/certificate.pem" + if MSYS=winsymlinks:nativestrict ln -s "${artifact_dir}/certificate.pem" \ + "${artifact_dir}/uki-signing-ca.pem" 2>/dev/null && + [[ -L "${artifact_dir}/uki-signing-ca.pem" ]]; then + _LOCAL_IPE_SIGNING_MODE=disabled + create_gallery_image_version 1.0.0 test.vhd + [[ "$(<"${route}")" == rest ]] + [[ "$(jq -r '.properties.securityProfile.uefiSettings.additionalSignatures.db[0].value[0]' "${request}")" == not-a-certificate ]] + else + echo "SKIP: gallery certificate symlink check (native symlinks unavailable)" + fi + printf 'PASS: non-IPE gallery certificates retain baseline handling; IPE uses captured bytes\n' +} + +assert_arm_guard_is_independent_of_ipe() { + warn() { :; } + BOARD=arm64-usr + local capability spec canonical minimum value safe_value option length override rc + local az_calls="${TEST_TMPDIR}/arm-override-az-calls" + az() { + printf '%s\n' "$@" > "${az_calls}" + printf '{"id":"test-vm"}\n' + } + for capability in false true; do + ACL_IPE_CAPABLE="${capability}" + SECURE_BOOT_ENABLED=false + AZ_VM_ARGS="" + _enforce_arm_security_contract >/dev/null + [[ "${SECURE_BOOT_ENABLED}" == true ]] + for spec in \ + "--size:--si:Standard_D2ps_v5:Standard_D2ps_v6" \ + "--security-type:--secu:Standard:TrustedLaunch" \ + "--enable-vtpm:--enable-v:false:true" \ + "--enable-secure-boot:--enable-s:false:true"; do + IFS=: read -r canonical minimum value safe_value <<< "${spec}" + for (( length=${#minimum}; length<=${#canonical}; length++ )); do + option="${canonical:0:length}" + for override in "${option} ${value}" "${option}=${value}" \ + "${option}" "${option}="; do + AZ_VM_ARGS="${override}" + if _enforce_arm_security_contract >/dev/null 2>&1; then + printf 'FAIL: ARM guard accepted %s (IPE=%s)\n' "${override}" "${capability}" >&2 + return 1 + fi + done + done + AZ_VM_ARGS="${minimum}=${safe_value}" + if _enforce_arm_security_contract >/dev/null 2>&1; then + printf 'FAIL: ARM guard accepted safe-value override %s (IPE=%s)\n' \ + "${AZ_VM_ARGS}" "${capability}" >&2 + return 1 + fi + AZ_VM_ARGS="${minimum}=${value}" + rc=0 + if _try_vm_create test-rg test-vm test-image test-sku test-region >/dev/null 2>&1; then + rc=0 + else + rc=$? + fi + if [[ ${rc} -ne 2 || -e "${az_calls}" ]]; then + printf 'FAIL: ARM override %s reached Azure CLI (IPE=%s): rc=%s\n' \ + "${AZ_VM_ARGS}" "${capability}" "${rc}" >&2 + return 1 + fi + done + for AZ_VM_ARGS in \ + $' \t--user-data=config.ign\r\n--siz=Standard_D2ps_v6' \ + $'--tags note=ok\n--secu Standard' \ + $'--imds-mode Enforced\t--enable-v=false' \ + $'--user-data=config.ign\r--enable-s=false'; do + if _enforce_arm_security_contract >/dev/null 2>&1; then + printf 'FAIL: ARM guard accepted multiline override %q (IPE=%s)\n' \ + "${AZ_VM_ARGS}" "${capability}" >&2 + return 1 + fi + done + for AZ_VM_ARGS in "" "--user-data config.ign --imds-mode Enforced" \ + "--tags note=--size --user-data=--si" \ + "--s=value --sec=value --enable-=false --size-extra=value"; do + _enforce_arm_security_contract + done + done + + BOARD=amd64-usr + ACL_IPE_CAPABLE=false + AZ_VM_ARGS="--siz=Standard_B1s" + _enforce_arm_security_contract + rc=0 + if _try_vm_create test-rg test-vm test-image test-sku test-region >/dev/null 2>&1; then + rc=0 + else + rc=$? + fi + if [[ ${rc} -ne 0 ]] || ! grep -Fxq -- '--siz=Standard_B1s' "${az_calls}"; then + printf 'FAIL: non-ARM launch did not forward VM size override: rc=%s\n' "${rc}" >&2 + return 1 + fi + printf 'PASS: ARM option guard blocks Azure CLI abbreviations before VM creation in both IPE modes\n' +} + assert_classification SkuNotAvailable \ "The requested VM size is not available in this location." \ 1 RETRYABLE_VM_CREATE_ERROR @@ -169,6 +558,12 @@ assert_classification AuthorizationFailed \ 2 "" ( assert_successful_vm_create_returns_cli_output ) ( assert_arm_warning_does_not_corrupt_vm_result ) +( assert_ipe_contract_rejects_before_az ) +( assert_ipe_vm_argv_is_canonical ) +( assert_local_ipe_artifact_contract ) +( assert_non_ipe_local_artifact_compatibility ) +( assert_gallery_certificate_compatibility ) +( assert_arm_guard_is_independent_of_ipe ) assert_vm_size_family_parsing() { local test_case vm_size expected actual diff --git a/acl/validate/validate_azure.sh b/acl/validate/validate_azure.sh index 49d0c407c54..ae1b3dc9b37 100755 --- a/acl/validate/validate_azure.sh +++ b/acl/validate/validate_azure.sh @@ -19,6 +19,9 @@ [[ -n "${_VALIDATE_AZURE_LOADED:-}" ]] && return 0 _VALIDATE_AZURE_LOADED=1 +# shellcheck disable=SC1091 # Path is resolved from this sourced script at runtime. +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../build_library/rpm/ipe_artifact.sh" + # ── Azure-specific globals ───────────────────────────────────────── AZ_SUB_ID="${AZ_SUB_ID:?Must be set — Azure subscription ID}" @@ -40,6 +43,11 @@ VM_RG="" AZ_VM_ARGS="${AZ_VM_ARGS:-}" _VM_CREATE_RESULT="" AZ_MAX_PROVISIONING_TIMEOUTS="${AZ_MAX_PROVISIONING_TIMEOUTS:-2}" +_VM_IMAGE_DIR="" +_LOCAL_IPE_ARTIFACT_PATH="" +_LOCAL_IPE_SIGNING_MODE=disabled +_LOCAL_IPE_SIGNING_CERT="" +_LOCAL_IPE_SIGNING_CERT_B64="" validate_azure_configuration() { if ! [[ "$AZ_MAX_PROVISIONING_TIMEOUTS" =~ ^[1-9][0-9]*$ ]]; then @@ -69,11 +77,87 @@ _enforce_arm_security_contract() { fi SECURE_BOOT_ENABLED=true - local forbidden_pattern='(^|[[:space:]])--(security-type|enable-vtpm|enable-secure-boot|size)(=|[[:space:]]|$)' - if [[ "${AZ_VM_ARGS:-}" =~ $forbidden_pattern ]]; then - error "--az-vm-args cannot override size or Trusted Launch security settings for Azure ARM VMs" + local -a az_vm_args=() + local argument option + ipe_split_argument_string az_vm_args "${AZ_VM_ARGS:-}" + # Azure CLI accepts unique long-option prefixes, not just full option names. + for argument in "${az_vm_args[@]}"; do + [[ "${argument}" == --* ]] || continue + option="$(ipe_option_name "${argument}")" + if ipe_option_is_abbreviation "${option}" --size --si || + ipe_option_is_abbreviation "${option}" --security-type --secu || + ipe_option_is_abbreviation "${option}" --enable-vtpm --enable-v || + ipe_option_is_abbreviation "${option}" --enable-secure-boot --enable-s; then + error "--az-vm-args cannot override size or Trusted Launch security settings for Azure ARM VMs" + return 1 + fi + done + return 0 +} + +_enforce_ipe_image_contract() { + [[ "${ACL_IPE_CAPABLE:-false}" == "true" ]] || return 0 + local -a az_vm_args=() + + if [[ "${REUSE_IMAGE:-false}" == "true" ]]; then + error "--reuse-image is not supported for IPE-capable Azure images: latest gallery capability and signing provenance are not verified" + return 1 + fi + ipe_split_argument_string az_vm_args "${AZ_VM_ARGS:-}" + if ! ipe_reject_azure_image_overrides "${az_vm_args[@]}"; then + error "--az-vm-args cannot override the image for IPE-capable Azure VMs" + return 1 + fi +} + +_prepare_local_ipe_artifact_contract() { + local vm_image_path="$1" + local image_dir canonical_image_path signing_mode certificate + + _enforce_arm_security_contract || return 1 + image_dir="$(cd -P -- "$(dirname -- "${vm_image_path}")" && pwd -P)" || { + error "Cannot resolve Azure image directory: ${vm_image_path}" + return 1 + } + canonical_image_path="${image_dir}/$(basename -- "${vm_image_path}")" + signing_mode="$( + ipe_resolve_artifact_signing_mode "$(dirname "${canonical_image_path}")" "${ACL_IPE_CAPABLE:-}" + )" || return 1 + + _VM_IMAGE_DIR="$(dirname "${canonical_image_path}")" + _LOCAL_IPE_ARTIFACT_PATH="" + _LOCAL_IPE_SIGNING_MODE="${signing_mode}" + _LOCAL_IPE_SIGNING_CERT="" + _LOCAL_IPE_SIGNING_CERT_B64="" + + # Non-IPE launches retain the existing upload and certificate handling. + if [[ "${signing_mode}" == "disabled" ]]; then + ACL_IPE_CAPABLE=false + _LOCAL_IPE_ARTIFACT_PATH="${canonical_image_path}" + export ACL_IPE_CAPABLE + return 0 + fi + + ipe_validate_local_vhd_contract "${canonical_image_path}" true >/dev/null || return 1 + certificate="${_VM_IMAGE_DIR}/uki-signing-ca.pem" + if [[ -e "${certificate}" || -L "${certificate}" || + "${SECURE_BOOT_ENABLED:-true}" == "true" ]]; then + ipe_validate_signing_certificate "${certificate}" || return 1 + if ! _LOCAL_IPE_SIGNING_CERT_B64="$( + openssl x509 -in "${certificate}" -outform DER | + base64 | + tr -d '\r\n' + )" || [[ -z "${_LOCAL_IPE_SIGNING_CERT_B64}" ]]; then + echo "Failed to encode IPE signing certificate: ${certificate}" >&2 + return 1 + fi + _LOCAL_IPE_SIGNING_CERT="${certificate}" fi + + ACL_IPE_CAPABLE=true + _LOCAL_IPE_ARTIFACT_PATH="${canonical_image_path}" + export ACL_IPE_CAPABLE } # Resolve Azure VM size and image definition based on BOARD. @@ -530,6 +614,7 @@ gallery_image_version_exists() { local image_version="$1" local state state=$(az sig image-version show \ + --subscription "$AZ_SUB_ID" \ --resource-group "$AZ_GALLERY_RG" \ --gallery-name "$AZ_ACG" \ --gallery-image-definition "$AZ_VM_IMAGE_DEF" \ @@ -570,14 +655,26 @@ create_gallery_image_version() { replication_mode="Full" fi - # Check for ephemeral UKI signing cert next to the VHD. When present, we - # must use the REST API to enroll it in the image's Secure Boot db (az CLI - # does not expose securityProfile on image-version create). - local signing_cert="${_VM_IMAGE_DIR}/uki-signing-ca.pem" - if [[ -f "${signing_cert}" ]]; then - info "Found UKI signing cert: ${signing_cert} — using REST API with Secure Boot profile" - local cert_b64 - cert_b64=$(grep -v '^-----' "${signing_cert}" | tr -d '\n\r') + # IPE uses the certificate captured at preflight; other images retain the + # existing adjacent-PEM handling, including symlinked certificates. + local signing_cert="${_LOCAL_IPE_SIGNING_CERT}" + local cert_b64="${_LOCAL_IPE_SIGNING_CERT_B64}" + if [[ "${_LOCAL_IPE_SIGNING_MODE}" == "disabled" ]]; then + signing_cert="" + cert_b64="" + if [[ -f "${_VM_IMAGE_DIR}/uki-signing-ca.pem" ]]; then + signing_cert="${_VM_IMAGE_DIR}/uki-signing-ca.pem" + cert_b64=$(grep -v '^-----' "${signing_cert}" | tr -d '\n\r') || { + error "Could not read UKI signing certificate: ${signing_cert}" + return 1 + } + fi + elif [[ "${SECURE_BOOT_ENABLED:-true}" == "true" && -z "${cert_b64}" ]]; then + error "IPE-capable gallery publication is missing its validated signing certificate" + exit 1 + fi + if [[ -n "${signing_cert}" ]]; then + info "Using UKI signing cert: ${signing_cert} - using REST API with Secure Boot profile" local version_resource_id="/subscriptions/$(az account show --query id -o tsv)/resourceGroups/${AZ_GALLERY_RG}/providers/Microsoft.Compute/galleries/${AZ_ACG}/images/${AZ_VM_IMAGE_DEF}/versions/${image_version}" # Build targetRegions JSON array from computed target_regions. @@ -689,8 +786,11 @@ _try_vm_create() { local region="$5" shift 5 local -a extra_tags=("$@") + local -a az_vm_args=() _VM_CREATE_RESULT="" _enforce_arm_security_contract || return 2 + _enforce_ipe_image_contract || return 2 + ipe_split_argument_string az_vm_args "${AZ_VM_ARGS:-}" local boot_diagnostics_storage_name boot_diagnostics_storage_name=$(get_boot_diagnostics_storage_name "$vm_rg_name") @@ -712,8 +812,8 @@ _try_vm_create() { vm_create_args+=(--enable-secure-boot "${SECURE_BOOT_ENABLED:-true}") - if [[ -n "$AZ_VM_ARGS" ]]; then - vm_create_args+=($AZ_VM_ARGS) + if [[ ${#az_vm_args[@]} -gt 0 ]]; then + vm_create_args+=("${az_vm_args[@]}") fi local output output_file create_pid rc @@ -1195,6 +1295,23 @@ get_vm_rg_name() { start_vm_azure() { local vm_image_path="$1" + if [[ -z "${ACG_IMAGE_VERSION_ID}" && "${REUSE_IMAGE}" != "true" ]]; then + local image_dir canonical_vm_image_path + image_dir="$(cd -P -- "$(dirname -- "${vm_image_path}")" && pwd -P)" || + die "Cannot resolve Azure image directory: ${vm_image_path}" + canonical_vm_image_path="${image_dir}/$(basename -- "${vm_image_path}")" + if [[ "${_LOCAL_IPE_ARTIFACT_PATH}" != "${canonical_vm_image_path}" ]]; then + _prepare_local_ipe_artifact_contract "${canonical_vm_image_path}" || + die "Local Azure image does not satisfy the IPE trust contract" + fi + if [[ "${_LOCAL_IPE_SIGNING_MODE}" != "disabled" ]]; then + vm_image_path="${_LOCAL_IPE_ARTIFACT_PATH}" + fi + fi + + _enforce_ipe_image_contract || + die "Azure image selection does not satisfy the IPE contract" + section "Starting Azure VM for board '${BOARD}'" local vm_rg_name=$(get_vm_rg_name) @@ -1229,13 +1346,15 @@ start_vm_azure() { create_vm_azure "$vm_rg_name" "$image_version" else check_azure_infra - _VM_IMAGE_DIR="$(cd "$(dirname "${vm_image_path}")" && pwd)" local image_version image_version=$(get_next_image_version) # When BUILD_ID is set the version is deterministic (1.0.), # so the same image may already have been published by a prior run. if [[ -n "${BUILD_ID}" ]] && gallery_image_version_exists "$image_version"; then + if [[ "${_LOCAL_IPE_SIGNING_MODE}" != "disabled" ]]; then + die "Refusing to reuse gallery image version ${image_version} for an IPE-capable local VHD; use a new BUILD_ID or remove the stale version" + fi info "Gallery image version ${image_version} already exists and is ready — skipping VHD upload" else local blob_name="$(date +%y%m%d.%H%M%S)-${BUILD_ID:+${BUILD_ID}-}${IMG_NAME}.vhd" diff --git a/acl/validate/validate_common.sh b/acl/validate/validate_common.sh index 6bc6ea51918..9fe32bd116f 100755 --- a/acl/validate/validate_common.sh +++ b/acl/validate/validate_common.sh @@ -342,6 +342,30 @@ run_scripts_via_console() { start_vm() { local vm_image_path="$1" local board="$2" + if [[ "${VM_TYPE}" == "azure" ]]; then + if [[ -z "${ACG_IMAGE_VERSION_ID:-}" && + "${REUSE_IMAGE:-false}" != "true" ]]; then + _prepare_local_ipe_artifact_contract "${vm_image_path}" || + die "Local Azure image does not satisfy the IPE trust contract" + fi + _enforce_ipe_image_contract || + die "Azure image selection does not satisfy the IPE contract" + if [[ "${_LOCAL_IPE_SIGNING_MODE}" != "disabled" && + -z "${ACG_IMAGE_VERSION_ID:-}" && + "${REUSE_IMAGE:-false}" != "true" ]]; then + vm_image_path="${_LOCAL_IPE_ARTIFACT_PATH}" + fi + if [[ -z "${ACG_IMAGE_VERSION_ID:-}" && + "${REUSE_IMAGE:-false}" != "true" && + -n "${BUILD_ID:-}" && + "${_LOCAL_IPE_SIGNING_MODE}" != "disabled" ]]; then + local image_version + image_version="$(get_next_image_version)" + if gallery_image_version_exists "${image_version}"; then + die "Refusing to reuse gallery image version ${image_version} for an IPE-capable local VHD; use a new BUILD_ID or remove the stale version" + fi + fi + fi remove_old_vm section "Starting a ${VM_TYPE} VM '${VM_NAME}' Board: '${BOARD}'" case "$VM_TYPE" in @@ -934,7 +958,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 d6ab78b19d3..7448054da50 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..402b25544cc --- /dev/null +++ b/build_library/rpm/additional_files/acl-node-security-profile.sh @@ -0,0 +1,120 @@ +#!/bin/bash +# Shared Azure IMDS security-profile helpers for IPE and SELinux initramfs loaders. +# Cache the validated profile or lookup failure for the current boot. + +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_cache_failure() { + local cache_dir temporary_failure_cache + + 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}" +} + +acl_security_profile_jq_filter() { + cat <<'JQ' + if length != 1 then + error("IMDS response must contain exactly one JSON document") + elif (.[0] | type) != "array" then + error("IMDS tagsList response is not an array") + else + .[0] as $document + | + [ + $document[] + | select(type == "object" and .name? == $tag) + ] as $matches + | if ($matches | length) == 0 then + {present: false, value: ""} + elif ($matches | length) > 1 then + error("duplicate security profile tags") + elif ($matches[0].value | type) != "string" then + error("security profile tag value is not a string") + else + {present: true, value: $matches[0].value} + end + end +JQ +} + +acl_security_profile_parse() { + acl_usrbin jq -ser --arg tag "acl-node-security-profile" \ + "$(acl_security_profile_jq_filter) | .value" +} + +acl_security_profile() { + local cache_dir imds_tags security_profile temporary_cache + local i transport_succeeded=false + + 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 + transport_succeeded=true + break + fi + echo "ACL: IMDS not ready, retry ${i}/30" >&2 + sleep 1 + done + if [[ "${transport_succeeded}" != "true" ]]; then + echo "ACL: IMDS unreachable after 30 retries" >&2 + acl_security_profile_cache_failure + return 1 + fi + + if ! security_profile="$( + printf '%s' "${imds_tags}" | + acl_security_profile_parse 2>/dev/null + )"; then + echo "ACL: IMDS security profile response failed validation" >&2 + acl_security_profile_cache_failure + return 1 + fi + + 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..27ef095b712 --- /dev/null +++ b/build_library/rpm/additional_files/dracut-acl-ipe-load/acl-ipe-load.sh @@ -0,0 +1,112 @@ +#!/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}" + +[[ -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) 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 a38301b8fd1..5ce8ac22d08 100644 --- a/build_library/rpm/dracut_install.sh +++ b/build_library/rpm/dracut_install.sh @@ -303,9 +303,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..b07aaf12701 --- /dev/null +++ b/build_library/rpm/ensure_ephemeral_cert.sh @@ -0,0 +1,88 @@ +#!/bin/bash + +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Create or validate the per-build test certificate shared by the IPE policy +# 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 [create|require]}" +MODE="${2:-create}" +KEY="${CERT_DIR}/ca.key" +CERT="${CERT_DIR}/uki-signing-ca.pem" + +case "${MODE}" in + create|require) ;; + *) + echo "Invalid certificate mode '${MODE}' (expected create or require)" >&2 + exit 1 + ;; +esac + +validate_pair() { + local pair_key="${1:-${KEY}}" pair_cert="${2:-${CERT}}" + local key_digest cert_digest + + key_digest="$( + openssl pkey -in "${pair_key}" -pubout -outform DER 2>/dev/null | + sha256sum | + cut -d' ' -f1 + )" || return 1 + cert_digest="$( + openssl x509 -in "${pair_cert}" -pubkey -noout 2>/dev/null | + openssl pkey -pubin -outform DER 2>/dev/null | + sha256sum | + cut -d' ' -f1 + )" || return 1 + + [[ -n "${key_digest}" && "${key_digest}" == "${cert_digest}" ]] +} + +if [[ -e "${KEY}" || -e "${CERT}" ]]; then + if [[ ! -s "${KEY}" || ! -s "${CERT}" ]]; then + echo "Incomplete ephemeral certificate pair in ${CERT_DIR}" >&2 + exit 1 + fi + if ! validate_pair; then + echo "Ephemeral certificate and private key do not match in ${CERT_DIR}" >&2 + exit 1 + fi + exit 0 +fi + +if [[ "${MODE}" == "require" ]]; then + echo "Required ephemeral certificate pair is missing from ${CERT_DIR}" >&2 + exit 1 +fi + +mkdir -p "$(dirname "${CERT_DIR}")" +WORK_DIR="$(mktemp -d "${CERT_DIR}.tmp.XXXXXX")" +cleanup() { + [[ -z "${WORK_DIR}" ]] || rm -rf -- "${WORK_DIR}" +} +trap cleanup EXIT +openssl req -x509 \ + -newkey rsa:2048 \ + -days 1 \ + -noenc \ + -keyout "${WORK_DIR}/ca.key" \ + -out "${WORK_DIR}/uki-signing-ca.pem" \ + -subj "/CN=ACL Ephemeral Signing $(date +%Y%m%d%H%M%S)" \ + -sha256 \ + -addext "basicConstraints=CA:FALSE" \ + -addext "extendedKeyUsage=codeSigning" +chmod 600 "${WORK_DIR}/ca.key" +validate_pair "${WORK_DIR}/ca.key" "${WORK_DIR}/uki-signing-ca.pem" + +rmdir "${CERT_DIR}" 2>/dev/null || true +if ! mv -T "${WORK_DIR}" "${CERT_DIR}" 2>/dev/null; then + if [[ -s "${KEY}" && -s "${CERT}" ]] && validate_pair; then + exit 0 + fi + echo "Could not publish ephemeral certificate pair to ${CERT_DIR}" >&2 + exit 1 +fi +WORK_DIR="" diff --git a/build_library/rpm/ipe_artifact.sh b/build_library/rpm/ipe_artifact.sh new file mode 100644 index 00000000000..eb4a6498f3c --- /dev/null +++ b/build_library/rpm/ipe_artifact.sh @@ -0,0 +1,134 @@ +#!/bin/bash +# Sourced helpers for IPE artifact metadata and local Azure launch validation. + +ipe_read_signing_mode() { + local marker="$1" + + if [[ ! -e "${marker}" && ! -L "${marker}" ]]; then + return 1 + fi + if [[ -L "${marker}" || ! -f "${marker}" || ! -r "${marker}" ]]; then + echo "Invalid IPE signing marker: ${marker} must be a readable regular file" >&2 + return 2 + fi + if cmp -s -- "${marker}" <(printf 'ephemeral\n'); then + printf '%s\n' ephemeral + return 0 + fi + if cmp -s -- "${marker}" <(printf 'esrp\n'); then + printf '%s\n' esrp + return 0 + fi + + echo "Invalid IPE signing marker: ${marker} must contain exactly one canonical ephemeral or esrp line" >&2 + return 2 +} + +ipe_resolve_artifact_signing_mode() { + local artifact_dir="$1" + local expected_capability="${2:-}" + local marker="${artifact_dir}/ipe-signing-mode" + local signing_mode rc + + case "${expected_capability}" in + ""|true|false) ;; + *) + echo "Invalid ACL_IPE_CAPABLE value: ${expected_capability}" >&2 + return 1 + ;; + esac + + if signing_mode="$(ipe_read_signing_mode "${marker}")"; then + if [[ "${expected_capability}" == "false" ]]; then + echo "IPE signing marker contradicts ACL_IPE_CAPABLE=false: ${marker}" >&2 + return 1 + fi + printf '%s\n' "${signing_mode}" + return 0 + else + rc=$? + fi + [[ ${rc} -eq 1 ]] || return 1 + + if [[ "${expected_capability}" == "true" ]]; then + echo "IPE-capable artifact is missing ${marker}" >&2 + return 1 + fi + if [[ -e "${artifact_dir}/acl-ipe-policy" || -L "${artifact_dir}/acl-ipe-policy" ]]; then + echo "IPE policy assets are present but ${marker} is missing" >&2 + return 1 + fi + + printf '%s\n' disabled +} + +ipe_validate_signing_certificate() { + local certificate="$1" + + if [[ -L "${certificate}" || ! -f "${certificate}" || ! -r "${certificate}" || ! -s "${certificate}" ]]; then + echo "IPE signing certificate must be a readable, nonempty regular file: ${certificate}" >&2 + return 1 + fi + if ! openssl x509 -in "${certificate}" -noout >/dev/null 2>&1; then + echo "IPE signing certificate is not a valid X.509 PEM: ${certificate}" >&2 + return 1 + fi +} + +ipe_validate_local_vhd_contract() { + local image="$1" + local expected_capability="${2:-}" + local artifact_dir signing_mode + + if [[ -L "${image}" || ! -f "${image}" || ! -r "${image}" || ! -s "${image}" ]]; then + echo "Azure VHD must be a readable, nonempty regular file: ${image}" >&2 + return 1 + fi + + artifact_dir="$(cd "$(dirname "${image}")" && pwd)" + signing_mode="$(ipe_resolve_artifact_signing_mode "${artifact_dir}" "${expected_capability}")" || + return 1 + printf '%s\n' "${signing_mode}" +} + +ipe_split_argument_string() { + local output_name="$1" + local argument_string="$2" + local -n output_ref="${output_name}" + + argument_string="${argument_string//$'\r'/ }" + argument_string="${argument_string//$'\n'/ }" + output_ref=() + # shellcheck disable=SC2034 # The nameref writes into the caller's array. + [[ -z "${argument_string}" ]] || read -r -a output_ref <<< "${argument_string}" +} + +ipe_option_name() { + printf '%s\n' "${1%%=*}" +} + +ipe_option_is_abbreviation() { + local option="$1" + local canonical="$2" + local minimum_prefix="$3" + + [[ "${option}" == "${canonical}" ]] || + [[ "${option}" == "${minimum_prefix}"* && "${canonical}" == "${option}"* ]] +} + +ipe_reject_azure_image_overrides() { + local argument option + + for argument; do + if [[ "${argument}" == -i* ]]; then + echo "IPE-capable Azure launches cannot override -i" >&2 + return 1 + fi + [[ "${argument}" == --* ]] || continue + option="$(ipe_option_name "${argument}")" + if ipe_option_is_abbreviation "${option}" --image --i; then + echo "IPE-capable Azure launches cannot override ${option}" >&2 + return 1 + fi + done +} diff --git a/build_library/rpm/rpm_install.sh b/build_library/rpm/rpm_install.sh index fb9178b734b..ff9984d1ab5 100644 --- a/build_library/rpm/rpm_install.sh +++ b/build_library/rpm/rpm_install.sh @@ -874,6 +874,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 +1168,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..7a91e16375b 100755 --- a/build_library/rpm/sign_uki_ephemeral.sh +++ b/build_library/rpm/sign_uki_ephemeral.sh @@ -8,16 +8,18 @@ # second stage) and the UKI + addons (EFI/Linux/*.efi, acl/uki-addons/*). # Shim itself (EFI/BOOT/BOOT.EFI) is Microsoft-signed and left alone. # -# This script generates a one-time RSA 2048-bit certificate and signs all -# applicable EFI binaries on the mounted ESP. The public certificate +# This script signs applicable EFI binaries using a newly generated certificate +# or the supplied per-build signing pair. The public certificate # (ca.pem) is written to the output directory so it can be enrolled in: # - 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 +# and IPE policy candidate. +EPHEMERAL_CERT_DIR="${3:-}" CERT_NAME="uki-signing-ca.pem" @@ -64,25 +69,37 @@ 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 policy candidate's cert so test VMs need one enrolled signer. + 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 and IPE policy" +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 +141,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 bfaf1969a4c..772d4d6cac4 100755 --- a/build_library/rpm/uki_install.sh +++ b/build_library/rpm/uki_install.sh @@ -33,6 +33,14 @@ switch_to_strict_mode . "${BUILD_LIBRARY_DIR}/toolchain_util.sh" || exit 1 . "${BUILD_LIBRARY_DIR}/board_options.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 x86_64-efi) @@ -159,6 +167,28 @@ OSREL [[ -n "${verity_uuid_content}" ]] || die "UKI/RPM: verity UUID file '${FLAGS_verity_uuid}' is empty" fi + if [[ "${IPE_CAPABLE}" == "true" && ${FLAGS_verity} -ne ${FLAGS_TRUE} ]]; then + die "UKI/RPM: IPE assets require a /usr dm-verity root hash" + 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 # mount.usr is in the main UKI (not the addon) because it is @@ -172,6 +202,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: The main UKI cmdline contains only slot-independent args. # Slot-specific args are delivered via UKI addons: # @@ -284,6 +317,14 @@ OSREL sudo cp "${uki_output}" "${ESP_DIR}/EFI/Linux/${uki_name}" info "UKI/RPM: Installed UKI → EFI/Linux/${uki_name}" + 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/build_library/rpm/verify_ipe_signer_continuity.sh b/build_library/rpm/verify_ipe_signer_continuity.sh new file mode 100755 index 00000000000..1b874ddbfb4 --- /dev/null +++ b/build_library/rpm/verify_ipe_signer_continuity.sh @@ -0,0 +1,111 @@ +#!/bin/bash + +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Verify the staged IPE signer and selected UKI's credential binding before +# image conversion re-signs the UKI. + +set -euo pipefail + +CERT_DIR="${1:?usage: verify_ipe_signer_continuity.sh }" +ARTIFACT_DIR="${2:?usage: verify_ipe_signer_continuity.sh }" +ESP_DIR="${3:?usage: verify_ipe_signer_continuity.sh }" +CERT="${CERT_DIR}/uki-signing-ca.pem" +STAGED_POLICY="${ARTIFACT_DIR}/acl-ipe-policy/acl-ipe-policy.p7b.cred" + +verify_attached_cms() { + local signature="$1" + + openssl smime -verify -inform der -binary \ + -in "${signature}" \ + -nointern -certfile "${CERT}" -noverify \ + -out /dev/null >/dev/null 2>&1 +} + +[[ -s "${CERT}" ]] || { + echo "IPE signer certificate is missing: ${CERT}" >&2 + exit 1 +} +[[ -s "${STAGED_POLICY}" ]] || { + echo "Staged IPE policy credential is missing: ${STAGED_POLICY}" >&2 + exit 1 +} +verify_attached_cms "${STAGED_POLICY}" || { + echo "Staged IPE policy is not signed by ${CERT}" >&2 + exit 1 +} + +config="${ESP_DIR}/loader/loader.conf" +[[ -r "${config}" ]] || { + echo "IPE UKI loader configuration is missing: ${config}" >&2 + exit 1 +} +uki_name="" +while read -r key value extra; do + [[ "${key}" == "default" ]] || continue + if [[ -n "${uki_name}" || -n "${extra:-}" || + ! "${value:-}" =~ ^[A-Za-z0-9_.+-]+[.]efi$ ]]; then + echo "IPE verification requires exactly one literal UKI default in ${config}" >&2 + exit 1 + fi + uki_name="${value}" +done < "${config}" +[[ -n "${uki_name}" ]] || { + echo "No UKI default found in ${config}" >&2 + exit 1 +} + +uki="${ESP_DIR}/EFI/Linux/${uki_name}" +installed="${uki}.extra.d/acl-ipe-policy.p7b.cred" +[[ -f "${installed}" && ! -L "${installed}" ]] && + cmp -s -- "${STAGED_POLICY}" "${installed}" || { + echo "Selected UKI credential differs from staged policy: ${installed}" >&2 + exit 1 + } + +cmdline="$(ukify inspect "${uki}" --json=short --section=.cmdline:text | + jq -er '.[".cmdline"].text | select(type == "string" and (contains("\u0000") | not))')" || { + echo "Cannot read the selected UKI command line: ${uki}" >&2 + exit 1 + } +read -r -a tokens <<< "${cmdline//$'\n'/ }" +expected_hash="$(sha256sum "${STAGED_POLICY}" | cut -d' ' -f1)" +hashes=() +for token in "${tokens[@]}"; do + if [[ "${token}" == acl.ipe.policy_sha256=* ]]; then + hashes+=("${token#*=}") + fi +done +if [[ "${#hashes[@]}" -ne 1 || "${hashes[0]:-}" != "${expected_hash}" ]]; then + echo "Selected UKI must bind exactly one matching IPE policy SHA-256: ${uki}" >&2 + exit 1 +fi + +shopt -s nullglob nocaseglob +for addon in "${uki}.extra.d"/*.addon.efi "${ESP_DIR}/loader/addons"/*.addon.efi; do + [[ -f "${addon}" && ! -L "${addon}" ]] || { + echo "IPE UKI addon must be a regular file: ${addon}" >&2 + exit 1 + } + addon_json="$(ukify inspect "${addon}" --json=short --section=.cmdline:text)" || { + echo "Cannot inspect IPE UKI addon: ${addon}" >&2 + exit 1 + } + addon_cmdline="$(jq -er ' + if type != "object" then error("invalid addon inspection") + elif has(".cmdline") then + .[".cmdline"].text | select(type == "string" and (contains("\u0000") | not)) + else "" end + ' <<< "${addon_json}")" || { + echo "Cannot read IPE UKI addon command line: ${addon}" >&2 + exit 1 + } + read -r -a addon_tokens <<< "${addon_cmdline//$'\n'/ }" + for token in "${addon_tokens[@]}"; do + if [[ "${token}" == acl.ipe.policy_sha256=* ]]; then + echo "IPE UKI addon must not set acl.ipe.policy_sha256: ${addon}" >&2 + exit 1 + fi + done +done diff --git a/image_to_vm.sh b/image_to_vm.sh index 0af6641b8cc..63d205ddee8 100755 --- a/image_to_vm.sh +++ b/image_to_vm.sh @@ -21,6 +21,8 @@ assert_not_root_user . "${BUILD_LIBRARY_DIR}/build_image_util.sh" || exit 1 . "${BUILD_LIBRARY_DIR}/vm_image_util.sh" || exit 1 . "${BUILD_LIBRARY_DIR}/cros_vm_constants.sh" || exit 1 +# shellcheck disable=SC1091 # BUILD_LIBRARY_DIR is initialized by common.sh. +. "${BUILD_LIBRARY_DIR}/rpm/ipe_artifact.sh" || exit 1 # UKI mode uses a dedicated disk layout without BIOS and unused partitions if [[ "${BOOTLOADER_MODE:-}" == "uki" ]]; then @@ -136,15 +138,71 @@ install_oem_package install_oem_sysext run_fs_hook +has_ipe_assets() { + local artifact_dir="$1" esp_dir="$2" + + [[ -e "${artifact_dir}/acl-ipe-policy/acl-ipe-policy.p7b.cred" ]] || + find "${esp_dir}/EFI/Linux" -type f \ + -path '*.efi.extra.d/acl-ipe-policy.p7b.cred' \ + -print -quit 2>/dev/null | + grep -q . +} + +validate_ipe_marker_consistency() { + local artifact_dir="$1" esp_dir="$2" + local signing_mode + + signing_mode="$(ipe_resolve_artifact_signing_mode "${artifact_dir}")" || + return 1 + if [[ "${signing_mode}" == "disabled" ]] && + has_ipe_assets "${artifact_dir}" "${esp_dir}"; then + echo "IPE assets are present without a valid ipe-signing-mode marker" >&2 + return 1 + fi + printf '%s\n' "${signing_mode}" +} + +get_disabled_vm_signing_dir() { + local output_dir="$1" + printf '%s\n' "${ACL_EPHEMERAL_CERT_DIR:-$(dirname "${output_dir}")/.acl-secureboot-signing/$(basename "${output_dir}")}" +} + # Sign UKI EFI files with an ephemeral key for Secure Boot testing. At this # point the ESP is still mounted at ${VM_TMP_ROOT}/boot with all EFI files in # place (UKI + addons). The public certificate is written to the image # 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 + ipe_signing_mode="$( + validate_ipe_marker_consistency "${FLAGS_from}" "${VM_TMP_ROOT}/boot" + )" || die_notrace "IPE artifact metadata is inconsistent with installed assets" + if [[ "${ipe_signing_mode}" != "disabled" ]]; then + ephemeral_cert_dir="${FLAGS_from}/acl-ipe-ephemeral" + "${BUILD_LIBRARY_DIR}/rpm/ensure_ephemeral_cert.sh" \ + "${ephemeral_cert_dir}" require || + die_notrace "IPE artifact has missing or invalid signing material" + bash "${BUILD_LIBRARY_DIR}/rpm/verify_ipe_signer_continuity.sh" \ + "${ephemeral_cert_dir}" \ + "${FLAGS_from}" \ + "${VM_TMP_ROOT}/boot" || + die_notrace "IPE policy signature or UKI credential binding verification failed" + else + # Share a signer across this build's test and production conversions, + # outside published artifacts. FLAGS_to already resolves latest links. + ephemeral_cert_dir="$(get_disabled_vm_signing_dir "${FLAGS_to}")" + "${BUILD_LIBRARY_DIR}/rpm/ensure_ephemeral_cert.sh" \ + "${ephemeral_cert_dir}" create || + die_notrace "Failed to prepare the per-build Secure Boot certificate" + fi "${BUILD_LIBRARY_DIR}/rpm/sign_uki_ephemeral.sh" \ "${VM_TMP_ROOT}/boot" \ - "$(_dst_dir)" + "$(_dst_dir)" \ + "${ephemeral_cert_dir}" + if [[ "${ipe_signing_mode}" == "disabled" ]]; then + rm -f "$(_dst_dir)/ipe-signing-mode" + else + printf '%s\n' "${ipe_signing_mode}" > "$(_dst_dir)/ipe-signing-mode" + fi fi # Changes done, glue it together 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_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"