From 2c65927a278d972137128ee30eebb38fedadd057 Mon Sep 17 00:00:00 2001 From: Roy Hopkins Date: Mon, 8 Jun 2026 15:56:12 +0100 Subject: [PATCH] Add CvmImageBuilder - Confidential Virtual Machine vhdx build script This adds a new ConfidentialComputing tools folder containing the CvmImageBuilder script. The script can be used to create a Hyper-V vhdx file that uses measured boot and dm-verity to preserve boot integrity, allowing this to be verified by remote attestation. This is suitable for use in Confidential VMs. --- .../CvmImageBuilder/.dockerignore | 12 + .../CvmImageBuilder/Dockerfile | 60 ++ .../CvmImageBuilder/README.md | 659 +++++++++++++++ .../CvmImageBuilder/build-cvm-image.sh | 799 ++++++++++++++++++ .../CvmImageBuilder/build-docker.ps1 | 121 +++ .../CvmImageBuilder/docker-build.sh | 223 +++++ .../apply-cloud-init-config.service | 14 + .../rootfs-files/apply-cloud-init-config.sh | 120 +++ .../rootfs-files/cloud-init-hardened.yaml | 35 + .../cloud-init-local-override.conf | 4 + .../rootfs-files/emergency-disable.conf | 5 + .../rootfs-files/etc-overlay.service | 14 + .../rootfs-files/setup-etc-overlay.sh | 29 + .../CvmImageBuilder/rootfs-files/sources.list | 3 + .../CvmImageBuilder/rootfs-files/verity.conf | 3 + .../CvmImageBuilder/scripts/calc_pcr11.sh | 142 ++++ .../CvmImageBuilder/scripts/calc_pcr4.sh | 151 ++++ .../CvmImageBuilder/scripts/calc_pe_hash.sh | 127 +++ .../CvmImageBuilder/scripts/pcr_helpers.sh | 106 +++ 19 files changed, 2627 insertions(+) create mode 100644 ConfidentialComputing/CvmImageBuilder/.dockerignore create mode 100644 ConfidentialComputing/CvmImageBuilder/Dockerfile create mode 100644 ConfidentialComputing/CvmImageBuilder/README.md create mode 100644 ConfidentialComputing/CvmImageBuilder/build-cvm-image.sh create mode 100644 ConfidentialComputing/CvmImageBuilder/build-docker.ps1 create mode 100644 ConfidentialComputing/CvmImageBuilder/docker-build.sh create mode 100644 ConfidentialComputing/CvmImageBuilder/rootfs-files/apply-cloud-init-config.service create mode 100644 ConfidentialComputing/CvmImageBuilder/rootfs-files/apply-cloud-init-config.sh create mode 100644 ConfidentialComputing/CvmImageBuilder/rootfs-files/cloud-init-hardened.yaml create mode 100644 ConfidentialComputing/CvmImageBuilder/rootfs-files/cloud-init-local-override.conf create mode 100644 ConfidentialComputing/CvmImageBuilder/rootfs-files/emergency-disable.conf create mode 100644 ConfidentialComputing/CvmImageBuilder/rootfs-files/etc-overlay.service create mode 100644 ConfidentialComputing/CvmImageBuilder/rootfs-files/setup-etc-overlay.sh create mode 100644 ConfidentialComputing/CvmImageBuilder/rootfs-files/sources.list create mode 100644 ConfidentialComputing/CvmImageBuilder/rootfs-files/verity.conf create mode 100644 ConfidentialComputing/CvmImageBuilder/scripts/calc_pcr11.sh create mode 100644 ConfidentialComputing/CvmImageBuilder/scripts/calc_pcr4.sh create mode 100644 ConfidentialComputing/CvmImageBuilder/scripts/calc_pe_hash.sh create mode 100644 ConfidentialComputing/CvmImageBuilder/scripts/pcr_helpers.sh diff --git a/ConfidentialComputing/CvmImageBuilder/.dockerignore b/ConfidentialComputing/CvmImageBuilder/.dockerignore new file mode 100644 index 00000000..71937cb2 --- /dev/null +++ b/ConfidentialComputing/CvmImageBuilder/.dockerignore @@ -0,0 +1,12 @@ +build/ +rootfs/ +*.vhdx +*.raw +*.img +*.squashfs +*.hash +*.log +.git/ +.gitignore +README.md +*.md diff --git a/ConfidentialComputing/CvmImageBuilder/Dockerfile b/ConfidentialComputing/CvmImageBuilder/Dockerfile new file mode 100644 index 00000000..d87eb1df --- /dev/null +++ b/ConfidentialComputing/CvmImageBuilder/Dockerfile @@ -0,0 +1,60 @@ +# +# Copyright (c) Microsoft Corporation. All rights reserved. +# +# Description: +# Dockerfile to build an environment for creating integrity-protected VHDX images +# +# DisableDockerDetector "This Dockerfile creates CVM images for testing purposes and is not used in production." +FROM ubuntu:24.04 + +# Set environment variables +ENV DEBIAN_FRONTEND=noninteractive +ENV TZ=UTC + +# Install all build dependencies +RUN apt-get update && apt-get install -y \ + qemu-utils \ + dracut \ + systemd-boot-efi \ + systemd-ukify \ + libsquashfs-dev \ + cryptsetup-bin \ + squashfs-tools \ + debootstrap \ + openssh-server \ + rsync \ + bc \ + curl \ + gnupg \ + ca-certificates \ + apt-transport-https \ + lsb-release \ + sudo \ + parted \ + util-linux \ + kmod \ + udev \ + dosfstools \ + systemd \ + python3 \ + binutils \ + vim-common \ + osslsigncode \ + && rm -rf /var/lib/apt/lists/* + +# Create a working directory +WORKDIR /workspace + +# Copy the build scripts +COPY build-cvm-image.sh . +COPY scripts/ scripts/ + +# Make scripts executable +RUN chmod +x build-cvm-image.sh +RUN chmod +x scripts/*.sh + +# Create build output directories +RUN mkdir -p /workspace/build /workspace/out + +# Set the entrypoint to our build script +ENTRYPOINT ["./build-cvm-image.sh"] diff --git a/ConfidentialComputing/CvmImageBuilder/README.md b/ConfidentialComputing/CvmImageBuilder/README.md new file mode 100644 index 00000000..cf3de232 --- /dev/null +++ b/ConfidentialComputing/CvmImageBuilder/README.md @@ -0,0 +1,659 @@ +# Integrity-Protected CVM Image Builder + +## Introduction + +### What This Tool Does + +`build-cvm-image.sh` produces immutable, integrity-protected Ubuntu VM images designed for deployment as **Confidential VMs (CVMs)**. The output is a VHDX disk image containing a measured, read-only operating system with your workload baked in. + +### Why It Exists + +Confidential VMs provide hardware-level isolation using isolation technologies including AMD SEV-SNP and Intel TDX, with a **virtual TPM (vTPM)** that enables measured boot. However, the value of measured boot depends entirely on *what* is being measured. If the OS and workload can be modified after measurement, an attacker could tamper with the system while it still appears trustworthy. + +This tool solves that problem by building images where: + +- **The entire root filesystem is read-only**, compressed into a SquashFS image and protected by **dm-verity** — a kernel-level integrity verification mechanism that cryptographically validates every block read from disk. +- **Every component in the boot chain is measured** into the vTPM's Platform Configuration Registers (PCRs), from the UEFI firmware through to the kernel, initramfs, kernel command line, and dm-verity root hash. +- **Your workload is embedded in the verified image**. Application binaries, configuration files, and static content are include in the SquashFS read-only filesystem. Any modification will cause dm-verity to reject the read, or will produce different PCR measurements. +- **Remote attestation can prove image identity**. The predictable PCR values produced at build time can be used in attestation policies for **Secure Key Release (SKR)** — a mechanism where a key vault will only release secrets (encryption keys, TLS certificates, API tokens) to a VM that can prove it is running an unmodified, known-good image. +- **Azure tooling is pre-installed**. The Azure CLI (`az`), `tpm2-tools`, `cloud-init`, and the Azure-tuned Linux kernel (`linux-azure`) are baked into the base image, so workloads can authenticate, attest, and call Azure services out of the box. + +The intended workflow is: + +1. Build an image with your workload using this tool +2. Note the TPM PCR measurements produced by the build +3. Configure a key release policy that requires those exact PCR values +4. Deploy the VHDX to a CVM +5. The workload performs remote attestation, proves its integrity, and receives its secrets + +This ensures that sensitive data is only ever accessible to a verified, unmodified instance of your workload running inside a hardware-protected confidential VM. + +### Disclaimer + +This tool is built on existing open source components including Linux kernel dm-verity, SquashFS, systemd-stub, dracut, and other community-maintained software. While the design follows security best practices, **Microsoft makes no guarantees regarding the security, integrity, or fitness for purpose of images generated by this tool**. The software is provided "as is" without warranty of any kind. Users assume all risk associated with the use of generated images in any environment, including production confidential computing workloads. + +You are responsible for: +- Validating that the generated images meet your security requirements +- Conducting your own security review and testing +- Keeping dependencies and base images up to date +- Understanding the limitations of the underlying technologies + +### Image Architecture + +The generated VHDX contains three partitions: + +``` +┌─────────────────────────────────────┐ +│ EFI Partition (200MB) │ +│ Unified Kernel Image (UKI): │ +│ - Linux kernel │ +│ - initramfs with dm-verity setup │ +│ - Kernel command line (root hash) │ +│ - OS release metadata │ +└─────────────────────────────────────┘ +┌─────────────────────────────────────┐ +│ Root Partition (SquashFS) │ +│ - Compressed read-only rootfs │ +│ - Your workload, configs, content │ +│ - dm-verity verified on every read │ +└─────────────────────────────────────┘ +┌─────────────────────────────────────┐ +│ Hash Partition (512MB) │ +│ - dm-verity hash tree │ +│ - Cryptographic proof of rootfs │ +└─────────────────────────────────────┘ +``` + +At boot, the Unified Kernel Image (UKI) is loaded from the EFI partition. It contains the dm-verity root hash embedded in the kernel command line, which the initramfs uses to set up verified access to the SquashFS root partition. The hash partition contains the Merkle tree that dm-verity uses to verify every disk block on read. + +### Runtime Filesystem Layout + +Since the root filesystem is read-only, writable areas are provided via tmpfs mounts: + +| Mount Point | Type | Size | Persistence | +|-------------|------|------|-------------| +| `/` | SquashFS (dm-verity) | Compressed image | Read-only, persistent | +| `/etc` | Overlay (tmpfs upper) | 128MB | Ephemeral (seeded from overlay at boot) | +| `/var` | tmpfs | 512MB | Ephemeral | +| `/tmp` | tmpfs | 256MB | Ephemeral | +| `/home/` | tmpfs | 512MB | Ephemeral | +| `/root` | tmpfs | 128MB | Ephemeral | +| `/run` | tmpfs | 128MB | Ephemeral | + +**All tmpfs data is lost on reboot.** This is by design — there is no writable persistent storage. Applications that need persistent data should use remote storage (Azure Blob, NFS, databases) accessed via secrets obtained through attestation. + +--- + +## Getting Started + +### Prerequisites + +The build process requires a Linux environment with root access. There are three ways to run it: + +| Method | Platform | Requirements | +|--------|----------|-------------| +| Native | Ubuntu 22.04+ | Root/sudo, 20GB free disk | +| Docker (Linux/WSL2) | Linux or WSL2 | Docker Engine | +| Docker (Windows) | Windows + WSL2 | Docker Desktop with WSL2 backend | + +For a native build, the following packages must be installed on the build +host. The script installs any that are missing automatically, but they can +also be installed up front: + +```bash +sudo apt install -y \ + qemu-utils \ + dracut \ + systemd-boot-efi \ + libsquashfs-dev \ + cryptsetup-bin \ + squashfs-tools \ + debootstrap \ + openssh-server \ + rsync \ + bc +``` + +### Command-Line Reference + +``` +Usage: build-cvm-image.sh --username --image [OPTIONS] + +Required arguments: + --username Username for the created user account + --image Output VHDX filename + +Optional arguments: + --ssh-key SSH public key for key-based authentication + --password Prompt for a password during the build + --password-hash Use a pre-generated password hash instead of prompting + --passwordless-sudo Allow sudo without password prompt + --allow-ssh-password Allow password authentication over SSH + (requires --password or --password-hash) + --allow-serial-console Enable serial console login + (requires --password or --password-hash) + --packages Comma-separated list of apt packages to install + --package-dir Directory containing .deb files to install via dpkg + --rootfs-overlay Directory of files to overlay onto the rootfs + --insiders-fast Enable the packages.microsoft.com insiders-fast apt repo + --verbose-output Print the full build log to the console instead of just + the summary +``` + +### Your First Image + +The simplest invocation creates a basic image with a locked user account (no password): + +```bash +./build-cvm-image.sh \ + --username admin \ + --image my-first-cvm.vhdx +``` + +No password is set by default. Use `--ssh-key` to enable SSH key-based login, +add `--password` to be prompted for a password during the build, or pass a +pre-generated hash with `--password-hash` (useful for non-interactive builds). + +> **Note:** `--allow-ssh-password` and `--allow-serial-console` both require +> a password to be set via `--password` or `--password-hash` — the build will +> fail with an error if they are used without one. + +The build takes approximately 10–15 minutes. On completion, the VHDX image is +written to the `out/` directory and the intermediate build artifacts and TPM +PCR measurements are saved in the `build/` directory. + +### Adding SSH Key Authentication + +For remote access, add an SSH public key: + +```bash +./build-cvm-image.sh \ + --username admin \ + --image my-cvm.vhdx \ + --ssh-key ~/.ssh/id_rsa.pub +``` + +This enables SSH key authentication. The public key is stored in `/etc/skel/.ssh/authorized_keys` within the read-only rootfs and is copied into the user's home directory by `systemd-tmpfiles` on each boot. + +To also allow password-based SSH login, add `--allow-ssh-password` together +with `--password` (or `--password-hash`): + +```bash +./build-cvm-image.sh \ + --username admin \ + --image my-cvm.vhdx \ + --ssh-key ~/.ssh/id_rsa.pub \ + --password \ + --allow-ssh-password +``` + +#### Using a Pre-Generated Password Hash + +For non-interactive builds (CI/CD pipelines, scripted rebuilds), the build +script accepts a pre-generated password hash via `--password-hash` instead of +prompting interactively. Generate a SHA-256 crypt hash with `openssl passwd`: + +```bash +openssl passwd -5 '' +``` + +This prints a hash of the form `$5$$`. Pass it through to the +build script (quoted, because the value contains `$` characters that the +shell would otherwise expand): + +```bash +./build-cvm-image.sh \ + --username admin \ + --image my-cvm.vhdx \ + --ssh-key ~/.ssh/id_rsa.pub \ + --password-hash '$5$abcdEFGH$....' \ + --allow-ssh-password +``` + +The hash is written directly into `/etc/shadow` inside the rootfs, so the +plaintext password never appears in the build environment or the build log. + +### Running the Build from Different Platforms + +#### Native Ubuntu + +Run the script directly on an Ubuntu 22.04+ host. Missing build dependencies are installed automatically: + +```bash +./build-cvm-image.sh \ + --username admin \ + --image my-cvm.vhdx \ + --ssh-key ~/.ssh/id_rsa.pub +``` + +The output image is written to `out/my-cvm.vhdx`. + +#### WSL2 (Ubuntu) with Docker + +From a WSL2 Ubuntu terminal, use the Docker wrapper script. This builds a Docker container with all dependencies and runs the build inside it: + +```bash +./docker-build.sh \ + --username admin \ + --image my-cvm.vhdx \ + --ssh-key ~/.ssh/id_rsa.pub +``` + +The wrapper automatically mounts files (SSH keys, overlays, configs) into the container. The final VHDX appears in `out/` on the host and intermediate build artifacts in `build/`. + +To force a rebuild of the Docker image (e.g. after updating the build scripts): + +```bash +./docker-build.sh --docker-rebuild \ + --username admin \ + --image my-cvm.vhdx +``` + +#### Windows (PowerShell) + +From a PowerShell prompt, use the PowerShell wrapper. This invokes `docker-build.sh` via WSL2: + +```powershell +.\build-docker.ps1 ` + -Username admin ` + -Image my-cvm.vhdx ` + -SshKey "$env:USERPROFILE\.ssh\id_rsa.pub" +``` + +**Requirements:** +- Docker Desktop installed with **WSL2 backend** enabled +- WSL Integration enabled for your distro: *Docker Desktop → Settings → Resources → WSL Integration → Toggle ON → Apply & Restart* + +The output VHDX is written to the `out\` directory alongside the script. + +### Build Output + +After a successful build, the deliverables are split between two directories. + +The `out/` directory contains the artifacts intended for deployment and policy +configuration: + +| File | Description | +|------|-------------| +| `.vhdx` | Final VHDX image | +| `calculated_pcrs.txt` | Combined PCR 4 and PCR 11 summary used for SKR policies | + +The `build/` directory contains the intermediate artifacts and build logs: + +| File | Description | +|------|-------------| +| `pcr4.txt` | Expected TPM PCR 4 values (boot loader measurements) | +| `pcr11.txt` | Expected TPM PCR 11 values (UKI section measurements) | +| `uki.efi` | Unified Kernel Image | +| `rootfs.squashfs` | Compressed root filesystem | +| `rootfs.hash` | dm-verity hash tree | +| `.raw` | Raw disk image used as the source for the VHDX conversion | +| `initrd-verity.img` | Initramfs containing the dm-verity setup | +| `cmdline.txt` | Kernel command line (contains root hash) | +| `verity.log` | dm-verity setup log | +| `build.log` | Complete build log | + +The build also leaves the unpacked root filesystem in the `rootfs/` directory +at the top of the workspace. It is reused as a starting point for subsequent +builds and can safely be deleted if you want to force a clean rebuild. + +--- + +## Customising the Image + +There are three mechanisms for adding your workload and custom software to the image: + +| Mechanism | Use Case | How It Works | +|-----------|----------|-------------| +| `--packages` | Standard Ubuntu packages | Installed via `apt` during the build | +| `--package-dir` | Custom or pinned `.deb` files | Installed via `dpkg`, then `apt install -f` resolves dependencies | +| `--rootfs-overlay` | Files, scripts, configs, systemd services | Copied into the rootfs preserving directory structure | + +### Installing Packages with `--packages` + +Pass a comma-separated list of Ubuntu package names. These are installed via `apt install` inside the chroot during the build: + +```bash +./build-cvm-image.sh \ + --username admin \ + --image my-cvm.vhdx \ + --packages "nginx,python3,htop,tmux" +``` + +All packages and their dependencies are baked into the read-only SquashFS image. + +### Installing .deb Packages with `--package-dir` + +For packages not in the Ubuntu repositories, or when you need a specific version, download the `.deb` files into a directory and point the build at it: + +```bash +mkdir -p my-debs +cp custom-app_1.0_amd64.deb my-debs/ + +./build-cvm-image.sh \ + --username admin \ + --image my-cvm.vhdx \ + --package-dir ./my-debs +``` + +The build script installs all `.deb` files from the directory via `dpkg -i` and then runs `apt install -f -y` to resolve any missing dependencies from the Ubuntu repositories. + +You can combine both mechanisms: + +```bash +./build-cvm-image.sh \ + --username admin \ + --image my-cvm.vhdx \ + --packages "curl,jq" \ + --package-dir ./my-debs +``` + +### Using the Rootfs Overlay + +The `--rootfs-overlay` option takes a directory whose structure mirrors the target filesystem. Files are copied into the rootfs before it is sealed into the read-only SquashFS image. + +```bash +./build-cvm-image.sh \ + --username admin \ + --image my-cvm.vhdx \ + --rootfs-overlay ./my-overlay +``` + +#### How Files Are Placed + +The overlay is processed in two ways depending on the target path: + +| Overlay path | Destination in image | How it gets there | +|-------------|---------------------|-------------------| +| `my-overlay/usr/...` | `/usr/...` (read-only rootfs) | Copied directly into rootfs via `rsync` | +| `my-overlay/srv/...` | `/srv/...` (read-only rootfs) | Copied directly into rootfs via `rsync` | +| `my-overlay/opt/...` | `/opt/...` (read-only rootfs) | Copied directly into rootfs via `rsync` | +| `my-overlay/etc/...` | `/usr/local/etc-overlay-seed/...` | Seeded into writable `/etc` overlay at boot | +| `my-overlay/home/...` | Not copied | `/home` is tmpfs — use `systemd-tmpfiles` instead | + +Files under `etc/` in the overlay receive special treatment. Because `/etc` is a writable tmpfs overlay at runtime (required for services like cloud-init, SSH, and systemd), overlay `/etc` files are stored in `/usr/local/etc-overlay-seed/` within the read-only rootfs and copied into the live `/etc` at each boot by the `etc-overlay.service`. + +#### Enabling a Systemd Service + +To ensure a service starts automatically at boot, create a symlink in the overlay under `etc/systemd/system/multi-user.target.wants/`: + +```bash +mkdir -p my-overlay/etc/systemd/system/multi-user.target.wants + +# Enable a service that ships with an installed package +ln -sf /lib/systemd/system/nginx.service \ + my-overlay/etc/systemd/system/multi-user.target.wants/nginx.service + +# Or enable a custom service file you are also placing in the overlay +ln -sf /etc/systemd/system/my-app.service \ + my-overlay/etc/systemd/system/multi-user.target.wants/my-app.service +``` + +#### Handling Tmpfs Directories + +Since `/var`, `/tmp`, `/home`, and `/run` are all tmpfs (empty on boot), any service that expects directories under these paths must create them at runtime. The pattern is to use a oneshot service that runs before the main service: + +```ini +# my-overlay/etc/systemd/system/my-app-prepare.service +[Unit] +Description=Create runtime directories for my-app +Before=my-app.service +After=local-fs.target + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/bin/mkdir -p /var/log/my-app /var/lib/my-app +ExecStart=/bin/chown appuser:appuser /var/log/my-app /var/lib/my-app + +[Install] +WantedBy=multi-user.target +``` + +#### Example: Deploying a Script + +```bash +mkdir -p my-overlay/usr/local/bin + +cat > my-overlay/usr/local/bin/my-script.sh << 'EOF' +#!/bin/bash +echo "Hello from the integrity-protected CVM" +EOF + +chmod +x my-overlay/usr/local/bin/my-script.sh +``` + +The script is baked into the read-only rootfs at `/usr/local/bin/my-script.sh` and cannot be modified at runtime. + +--- + +## Advanced Configuration + +### SSH-Only Access (Default) + +By default no password is set, so SSH key authentication is the only way to access the VM. Combine with `--ssh-key` and `--passwordless-sudo` for a fully non-interactive CI/CD-friendly build: + +```bash +./build-cvm-image.sh \ + --username deploy \ + --image prod-cvm.vhdx \ + --ssh-key ./deploy_key.pub +``` + +> **Warning:** If you build without `--ssh-key`, without `--password`, and +> without `--password-hash`, the VM will be inaccessible. The build script +> prints a warning if this is detected. + +### Passwordless Sudo + +The `--passwordless-sudo` flag configures the user account to run `sudo` without a password prompt. This is useful for automated workloads and is required when no password is set: + +```bash +./build-cvm-image.sh \ + --username deploy \ + --image prod-cvm.vhdx \ + --ssh-key ./deploy_key.pub \ + --passwordless-sudo +``` + +This creates a `/etc/sudoers.d/99-` file with `NOPASSWD:ALL`. + +### Cloud-Init + +Cloud-init is always installed with a hardened security configuration that only processes metadata (hostname and network). All user-data is ignored. + +The hardened configuration: +- **Disables** all user-data processing (no scripts, no package installation, no user creation) +- **Allows** hostname and network configuration from metadata only +- Supports Azure and NoCloud datasources + +#### How Cloud-Init Data Is Loaded + +On first boot, the `apply-cloud-init-config.service` looks for a device with the `CIDATA` label (the standard NoCloud mechanism). If found, the cloud-init `meta-data` and `user-data` files are copied to the EFI partition for persistence across reboots. On subsequent boots, the configuration is loaded from the EFI partition directly. + +The hardened configuration ensures that even if `user-data` is present, its contents are ignored — only `meta-data` (hostname, network) is processed. + +### Network Configuration + +Network is configured exclusively via cloud-init metadata. If no cloud-init CD (`CIDATA` label) is found at boot, the network is not configured. + +To provide network configuration, attach a NoCloud ISO or use the Azure metadata service. Cloud-init processes the `network-config` file from the metadata source. + +### Microsoft Insiders-Fast Repository + +Some Microsoft components (for example, the Confidential Computing evidence +SDK) are only published to the `packages.microsoft.com` `insiders-fast` apt +repository. Add `--insiders-fast` to enable that repository inside the chroot +before packages are installed: + +```bash +./build-cvm-image.sh \ + --username admin \ + --image my-cvm.vhdx \ + --packages "edge-cc-base-attestation-sdk" \ + --insiders-fast +``` + +### Verbose Build Output + +By default the build script prints only high-level progress messages to the +console and writes the full log to `build/build.log`. Pass `--verbose-output` to +stream the complete log to the console as well as to the log file. This can be +useful when debugging a failing build or running in CI where the build log +artifact is not retained: + +```bash +./build-cvm-image.sh \ + --username admin \ + --image my-cvm.vhdx \ + --verbose-output +``` + +--- +## Example Invocations + +The following recipes combine the options described in the previous sections +into common end-to-end build scenarios. + +### Minimal Image + +A bare image with a locked user account. Useful as a smoke test of the build +environment; the resulting VM is not directly accessible. + +```bash +./build-cvm-image.sh \ + --username azureuser \ + --image azure-vm.vhdx +``` + +### SSH-Key Access + +The typical interactive development image — SSH key login only, no password. + +```bash +./build-cvm-image.sh \ + --username azureuser \ + --image azure-vm.vhdx \ + --ssh-key ~/.ssh/azure_key.pub +``` + +### CI/CD (Fully Automated) + +Non-interactive build suitable for pipelines: SSH key authentication and +passwordless `sudo`, with no prompts during the build itself. + +```bash +./build-cvm-image.sh \ + --username deploy \ + --image ci-vm.vhdx \ + --ssh-key ./deploy_key.pub \ + --passwordless-sudo +``` + +### Scripted Build with a Password + +Fully scripted build that still sets a login password, using a pre-generated +hash so no interactive prompt is required. + +```bash +HASH=$(openssl passwd -5 "$BUILD_PASSWORD") + +./build-cvm-image.sh \ + --username admin \ + --image admin-vm.vhdx \ + --ssh-key ~/.ssh/id_rsa.pub \ + --password-hash "$HASH" \ + --allow-ssh-password +``` + +### Development Image with Extra Tools + +Add common development packages from the Ubuntu archive. + +```bash +./build-cvm-image.sh \ + --username developer \ + --image dev-vm.vhdx \ + --ssh-key ~/.ssh/id_rsa.pub \ + --packages "git,build-essential,python3,nodejs" +``` + +### Workload Image with Custom Files + +Deploy a workload by combining a `.deb` package and a rootfs overlay (see the +lighttpd tutorial for a fully worked example). + +```bash +./build-cvm-image.sh \ + --username webadmin \ + --image webserver-cvm.vhdx \ + --package-dir ./webserver-debs \ + --rootfs-overlay ./webserver-overlay \ + --ssh-key ~/.ssh/id_rsa.pub +``` + +--- +## How Integrity Protection Works + +### The Chain of Trust + +The integrity of the image is maintained through a cryptographic chain that begins at the hardware and extends to the last byte of your workload: + +``` +Hardware (AMD SEV-SNP / Intel TDX) + └── vTPM (measures each boot stage) + └── UEFI Firmware (PCR 0-3) + └── Unified Kernel Image (measured into PCR 4) + ├── Linux kernel + ├── initramfs (contains veritysetup) + ├── Kernel command line (contains dm-verity root hash) + ├── OS release info + └── UKI sections (measured into PCR 11) + └── dm-verity root hash + └── SquashFS root filesystem + └── Your workload +``` + +### dm-verity + +The root filesystem is formatted as a compressed SquashFS image and verified at runtime using the Linux kernel's **dm-verity** subsystem. dm-verity uses a Merkle hash tree (stored in the hash partition) to verify every 4096-byte block read from the root partition. + +The root hash of the Merkle tree is embedded in the kernel command line, which is itself embedded in the UKI, which is measured into the TPM. This means: + +- If any byte in the rootfs is modified, the block verification fails and the read returns an error +- If the root hash is modified, the kernel command line changes, which changes the UKI, which changes the TPM measurement +- There is no way to modify the workload without producing a different set of PCR values + +### TPM PCR Measurements + +The build process calculates the expected values for two TPM PCRs: + +**PCR 4** — Boot loader code. Contains the hash of the Unified Kernel Image as measured by the UEFI firmware when it loads the EFI application. This measurement covers everything in the UKI: kernel, initramfs, command line (including the dm-verity root hash), and OS release metadata. + +**PCR 11** — UKI section measurements. Contains hashes of the individual UKI sections (`.linux`, `.osrel`, `.cmdline`, `.initrd`, `.uname`, `.sbat`) as extended by systemd-stub during early boot. These values are calculated by the build scripts `scripts/calc_pcr4.sh` and `scripts/calc_pcr11.sh`. + +The systemd PCR phase extension services (`systemd-pcrphase-initrd`, `systemd-pcrphase-sysinit`, `systemd-pcrphase`) are masked in the image to ensure PCR values remain predictable and match the build-time calculations. + +### Remote Attestation and Secure Key Release + +The PCR values from `out/calculated_pcrs.txt` can be used in an attestation policy to implement Secure Key Release: + +1. **Build time:** Record the PCR 4 and PCR 11 SHA-256 values +2. **Policy configuration:** Create a key release policy in Azure Key Vault (or your attestation service) that requires these exact PCR values +3. **Runtime:** The workload calls the attestation service, which verifies: + - The VM is a genuine CVM (hardware attestation via SEV-SNP/TDX) + - The TPM PCR values match the expected values (software attestation) +4. **Key release:** Only if both checks pass, the service releases the cryptographic keys + +This ensures that secrets are only ever accessible to an unmodified instance of your specific image running inside a hardware-protected confidential VM. + +> **Important:** Any change to the image — a different package version, a modified +> configuration file, an updated web page — produces different dm-verity hashes, +> different UKI content, and therefore different PCR values. After every rebuild, +> the key release policy must be updated with the new PCR values from +> `out/calculated_pcrs.txt`. + +### Emergency and Rescue Mode + +The emergency and rescue shells are disabled in the image to prevent interactive access before the boot process completes. This prevents a user from halting boot at an early stage to tamper with the system before measurements are finalised. + diff --git a/ConfidentialComputing/CvmImageBuilder/build-cvm-image.sh b/ConfidentialComputing/CvmImageBuilder/build-cvm-image.sh new file mode 100644 index 00000000..5c08532f --- /dev/null +++ b/ConfidentialComputing/CvmImageBuilder/build-cvm-image.sh @@ -0,0 +1,799 @@ +#!/usr/bin/env bash +# +# Copyright (c) Microsoft Corporation. All rights reserved. +# +# Description: +# A script to build immutable, integrity-protected images that are suitable for +# deploying into Azure Local CVMs. These images feature a read-only rootfs with +# dm-verity verification, TPM measurements, and cloud-init support. +# +set -euo pipefail + +# ============================================================================ +# Function Definitions +# ============================================================================ + +# Function to log to both console and file +log_progress() { + echo "$@" | tee -a "$LOGFILE" +} + +# Usage function +usage() { + echo "Usage: $0 --username --image [OPTIONS]" + echo "" + echo "Required arguments:" + echo " --username Username for the created user account" + echo " --image Output VHDX filename" + echo "" + echo "Optional arguments:" + echo " --password Prompt for a password during the build" + echo " --passwordless-sudo Allow sudo without password prompt (less secure)" + echo " --password-hash Use a pre-generated password hash instead of prompting for a password" + echo " --packages Comma-separated list of additional packages to install" + echo " --ssh-key Path to SSH public key file for passwordless login" + echo " --rootfs-overlay Directory containing files to overlay onto the rootfs" + echo " Files are copied preserving directory structure" + echo " --allow-ssh-password Allow password authentication over SSH (disabled by default)" + echo " --allow-serial-console Enable serial console login (disabled by default)" + echo " --package-dir Directory containing .deb packages to install via dpkg" + echo " All .deb files in the directory will be installed" + echo " --insiders-fast Enable packages.microsoft.com insiders-fast apt repo" + echo " --verbose-output Print the full build log to the console instead of just the summary" + echo "" + echo "Examples:" + echo " $0 --username user --image vm.vhdx" + echo " $0 --username user --image vm.vhdx --packages 'curl,wget,htop'" + echo " $0 --username user --image vm.vhdx --ssh-key ~/.ssh/id_rsa.pub" + echo " $0 --username user --image vm.vhdx --rootfs-overlay ./custom_files" + echo " $0 --username user --image vm.vhdx --password-hash '\$6\$rounds=5000\$saltsalt\$hashedpassword'" + exit 1 +} + +# Make sure the script host has all the packages required to build the image +install_dependencies() { + for pkg in qemu-utils dracut systemd-boot-efi libsquashfs-dev cryptsetup-bin squashfs-tools debootstrap openssh-server rsync bc; do + if ! dpkg -s "$pkg" >/dev/null 2>&1; then + log_progress " Installing $pkg" + sudo apt install -y "$pkg" >> "$LOGFILE" 2>&1 + fi + done +} + +# Prepare the build directory and rootfs +prepare_build_environment() { + # Remove existing rootfs if present + if [[ -d "$ROOTFS_DIR" ]]; then + # Delete the contents rather than removing the entire directory because the docker + # build mounts it as a volume which cannot be removed by the script. + sudo find "$ROOTFS_DIR" -mindepth 1 -delete 2>/dev/null || { + log_progress " WARNING: Could not delete some rootfs contents" + } + else + mkdir -p "$ROOTFS_DIR" + fi +} + +# Prepare and populate the rootfs using debootstrap and any overlays +# and custom packages. +create_ubuntu_rootfs() { + log_progress " Running debootstrap for Ubuntu Noble (24.04)..." + APT_CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/cvmos/apt" + mkdir -p "$APT_CACHE_DIR" + sudo debootstrap --verbose --arch=amd64 --cache-dir="$APT_CACHE_DIR" noble "$ROOTFS_DIR" http://archive.ubuntu.com/ubuntu/ >> "$LOGFILE" 2>&1 + + configure_tmpfs + install_packages + configure_services + configure_cloud_init + create_user_account + apply_rootfs_overlay + cleanup_chroot_mounts +} + +configure_tmpfs() { + log_progress " Configuring tmpfs mounts for runtime..." + echo "tmpfs /home tmpfs defaults,size=512M 0 0" | sudo tee -a "$ROOTFS_DIR/etc/fstab" >> "$LOGFILE" + echo "tmpfs /var tmpfs defaults,size=512M 0 0" | sudo tee -a "$ROOTFS_DIR/etc/fstab" >> "$LOGFILE" + echo "tmpfs /tmp tmpfs defaults,size=256M 0 0" | sudo tee -a "$ROOTFS_DIR/etc/fstab" >> "$LOGFILE" + echo "tmpfs /root tmpfs defaults,size=128M,uid=0,gid=0,mode=0700 0 0" | sudo tee -a "$ROOTFS_DIR/etc/fstab" >> "$LOGFILE" + echo "tmpfs /run tmpfs defaults,size=128M 0 0" | sudo tee -a "$ROOTFS_DIR/etc/fstab" >> "$LOGFILE" +} + +install_packages() { + log_progress " Installing required packages..." + + cp rootfs-files/sources.list "$ROOTFS_DIR/etc/apt/sources.list" + + sudo mount -t proc proc "$ROOTFS_DIR/proc" + sudo mount -t sysfs sysfs "$ROOTFS_DIR/sys" + sudo mount --bind /dev "$ROOTFS_DIR/dev" + + log_progress " Installing Microsoft GPG keys..." + sudo chroot "$ROOTFS_DIR" /bin/bash <<'KEY_EOF' >> "$LOGFILE" 2>&1 +set -euo pipefail +apt update +apt install -y ca-certificates wget apt-transport-https lsb-release gnupg + +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +# Legacy key (pre-Spring 2025 repos) +wget -q https://packages.microsoft.com/keys/microsoft.asc -O "$TMPDIR/microsoft.asc" +ACTUAL=$(sha256sum "$TMPDIR/microsoft.asc" | awk '{print $1}') +if [ "$ACTUAL" != "2fa9c05d591a1582a9aba276272478c262e95ad00acf60eaee1644d93941e3c6" ]; then + echo "SHA256 mismatch for microsoft.asc!" >&2; exit 1 +fi +gpg --dearmor "$TMPDIR/microsoft.asc" +mv "$TMPDIR/microsoft.asc.gpg" /etc/apt/trusted.gpg.d/ + +# Current key (Spring 2025+ repos) +wget -q https://packages.microsoft.com/keys/microsoft-2025.asc -O "$TMPDIR/microsoft-2025.asc" +ACTUAL=$(sha256sum "$TMPDIR/microsoft-2025.asc" | awk '{print $1}') +if [ "$ACTUAL" != "d45224d594d969f084232deaaf97c58ca502a9d964c362d7aaef5a76e16b3dd1" ]; then + echo "SHA256 mismatch for microsoft-2025.asc!" >&2; exit 1 +fi +gpg --dearmor "$TMPDIR/microsoft-2025.asc" +mv "$TMPDIR/microsoft-2025.asc.gpg" /etc/apt/trusted.gpg.d/ +KEY_EOF + + if [[ "$INSIDER_FAST" == "true" ]]; then + log_progress " Enabling packages.microsoft.com insiders-fast repo..." + sudo chroot "$ROOTFS_DIR" /bin/bash <<'INSIDER_EOF' >> "$LOGFILE" 2>&1 +set -euo pipefail + +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +# Add insiders-fast apt source +# Note the evidence SDK is only published to 22.04 at the moment, this should be updated with future releases. +wget -q https://packages.microsoft.com/config/ubuntu/22.04/insiders-fast.list -O "$TMPDIR/insiders-fast.list" +ACTUAL=$(sha256sum "$TMPDIR/insiders-fast.list" | awk '{print $1}') +if [ "$ACTUAL" != "2d7bf753c6036b8e894c93a65b0ce669906ebe54ba2db7107900e7e99ae47712" ]; then + echo "SHA256 mismatch for insiders-fast.list!" >&2; exit 1 +fi +cp "$TMPDIR/insiders-fast.list" /etc/apt/sources.list.d/microsoft-insiders-fast.list +INSIDER_EOF + fi + + log_progress " Adding Microsoft Azure CLI repository..." + sudo chroot "$ROOTFS_DIR" /bin/bash <> "$LOGFILE" 2>&1 +echo "deb [arch=amd64] https://packages.microsoft.com/repos/azure-cli/ \$(lsb_release -cs) main" | tee /etc/apt/sources.list.d/azure-cli.list +EOF + # apt update to install the keys and new repos. + sudo chroot "$ROOTFS_DIR" apt update >> "$LOGFILE" 2>&1 + + BASE_PACKAGES="openssh-server openssh-client systemd-resolved tpm2-tools vim bsdextrautils libtss2-dev linux-azure curl jq azure-cli systemd-boot-efi cloud-init" + + log_progress " Installing base packages: $BASE_PACKAGES" + sudo chroot "$ROOTFS_DIR" apt install -y --no-install-recommends $BASE_PACKAGES >> "$LOGFILE" 2>&1 + + if [[ -n "$ADDITIONAL_PACKAGES" ]]; then + PACKAGE_LIST=$(echo "$ADDITIONAL_PACKAGES" | tr ',' ' ') + log_progress " Installing additional packages: $PACKAGE_LIST" + sudo chroot "$ROOTFS_DIR" apt install -y --no-install-recommends $PACKAGE_LIST >> "$LOGFILE" 2>&1 + fi + + if [[ -n "$PACKAGE_DIR" ]]; then + log_progress " Installing .deb packages from: $PACKAGE_DIR" + local DEB_STAGING="/tmp/deb-packages" + sudo mkdir -p "$ROOTFS_DIR$DEB_STAGING" + sudo cp "$PACKAGE_DIR"/*.deb "$ROOTFS_DIR$DEB_STAGING/" + for deb in "$ROOTFS_DIR$DEB_STAGING"/*.deb; do + log_progress " Queued: $(basename "$deb")" + done + log_progress " Installing all .deb packages..." + sudo chroot "$ROOTFS_DIR" bash -c "dpkg -i $DEB_STAGING/*.deb" >> "$LOGFILE" 2>&1 + log_progress " Resolving remaining dependencies..." + sudo chroot "$ROOTFS_DIR" apt install -f -y >> "$LOGFILE" 2>&1 + sudo rm -rf "$ROOTFS_DIR$DEB_STAGING" + fi + + log_progress " Removing unneeded firmware directories..." + sudo rm -rf "$ROOTFS_DIR/usr/lib/firmware" "$ROOTFS_DIR/lib/firmware" 2>/dev/null || true +} + +configure_services() { + if [[ "$ALLOW_SERIAL_CONSOLE" == "true" ]]; then + log_progress " Configuring serial console access..." + sudo chroot "$ROOTFS_DIR" systemctl enable serial-getty@ttyS0.service >> "$LOGFILE" 2>&1 + else + log_progress " Masking serial console login (use --allow-serial-console to enable)..." + # The kernel cmdline includes console=ttyS0, which causes systemd-getty-generator + # to automatically start serial-getty@ttyS0.service at boot even if it has not been + # explicitly enabled. Masking the service prevents the generator from spawning it. + sudo ln -sf /dev/null "$ROOTFS_DIR/etc/systemd/system/serial-getty@ttyS0.service" + fi + + log_progress " Disabling emergency and rescue mode shells..." + sudo mkdir -p "$ROOTFS_DIR/etc/systemd/system/emergency.service.d" + sudo mkdir -p "$ROOTFS_DIR/etc/systemd/system/rescue.service.d" + sudo cp rootfs-files/emergency-disable.conf "$ROOTFS_DIR/etc/systemd/system/emergency.service.d/disable.conf" + sudo cp rootfs-files/emergency-disable.conf "$ROOTFS_DIR/etc/systemd/system/rescue.service.d/disable.conf" + + # Mask systemd PCR extension services (we calculate PCR values from UKI sections only) + log_progress " Masking systemd PCR extension services..." + for service in systemd-pcrphase-initrd systemd-pcrphase-sysinit systemd-pcrphase; do + sudo ln -sf /dev/null "$ROOTFS_DIR/etc/systemd/system/${service}.service" + done +} + +configure_cloud_init() { + log_progress " Configuring cloud-init with hardened security policy..." + sudo mkdir -p "$ROOTFS_DIR/etc/cloud/cloud.cfg.d" + + # Hardened cloud-init configuration baked into the read-only rootfs. + # Only metadata (hostname, network) is processed. All user-data is ignored. + # At runtime, cloud-init data files are loaded from EFI partition. The policy + # in the rootfs controls what cloud-init is allowed to do with the data. + sudo cp rootfs-files/cloud-init-hardened.yaml "$ROOTFS_DIR/etc/cloud/cloud.cfg.d/01-hardened.yaml" + + sudo mkdir -p "$ROOTFS_DIR/usr/local/bin" + sudo cp rootfs-files/setup-etc-overlay.sh "$ROOTFS_DIR/usr/local/bin/setup-etc-overlay.sh" + sudo chmod +x "$ROOTFS_DIR/usr/local/bin/setup-etc-overlay.sh" + + sudo cp rootfs-files/etc-overlay.service "$ROOTFS_DIR/etc/systemd/system/etc-overlay.service" + + # Install cloud-init config applicator service + sudo cp rootfs-files/apply-cloud-init-config.sh "$ROOTFS_DIR/usr/local/bin/apply-cloud-init-config.sh" + sudo chmod +x "$ROOTFS_DIR/usr/local/bin/apply-cloud-init-config.sh" + sudo cp rootfs-files/apply-cloud-init-config.service "$ROOTFS_DIR/etc/systemd/system/apply-cloud-init-config.service" + + # Install cloud-init-local override to force it to run when we enable it + sudo mkdir -p "$ROOTFS_DIR/etc/systemd/system/cloud-init-local.service.d" + sudo cp rootfs-files/cloud-init-local-override.conf "$ROOTFS_DIR/etc/systemd/system/cloud-init-local.service.d/override.conf" + + sudo chroot "$ROOTFS_DIR" systemctl enable etc-overlay.service >> "$LOGFILE" 2>&1 + sudo chroot "$ROOTFS_DIR" systemctl enable apply-cloud-init-config.service >> "$LOGFILE" 2>&1 + sudo chroot "$ROOTFS_DIR" systemctl enable cloud-init-local.service >> "$LOGFILE" 2>&1 + sudo chroot "$ROOTFS_DIR" systemctl enable cloud-init.service >> "$LOGFILE" 2>&1 + sudo chroot "$ROOTFS_DIR" systemctl enable cloud-config.service >> "$LOGFILE" 2>&1 + sudo chroot "$ROOTFS_DIR" systemctl enable cloud-final.service >> "$LOGFILE" 2>&1 +} + +create_user_account() { + log_progress " Creating user account and enabling SSH..." + + # Set password command - lock account if no password + sudo chroot "$ROOTFS_DIR" /bin/bash <> "$LOGFILE" 2>&1 + useradd -m -s /bin/bash $USERNAME + if [[ -n '${PASSWORD_HASH:-}' ]]; then + echo '$USERNAME:${PASSWORD_HASH:-}' | chpasswd -e + elif [[ -n '${PASSWORD:-}' ]]; then + echo '$USERNAME:${PASSWORD:-}' | chpasswd + else + passwd -l $USERNAME # Lock the account + fi + usermod -aG sudo $USERNAME +EOF + + # Validate and sanitize username to ensure safe format + SAFE_USERNAME=$(echo "$USERNAME" | tr -cd '[:alnum:]_-') + + # Configure sudo permissions based on --passwordless-sudo flag + if [[ "$PASSWORDLESS_SUDO" == "true" ]]; then + log_progress " Configuring passwordless sudo..." + sudo chroot "$ROOTFS_DIR" /bin/bash <> "$LOGFILE" 2>&1 +echo "$SAFE_USERNAME ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/99-$SAFE_USERNAME +chmod 0440 /etc/sudoers.d/99-$SAFE_USERNAME +EOF + else + log_progress " Configuring sudo with password requirement..." + sudo chroot "$ROOTFS_DIR" /bin/bash <> "$LOGFILE" 2>&1 +echo "$SAFE_USERNAME ALL=(ALL) ALL" > /etc/sudoers.d/99-$SAFE_USERNAME +chmod 0440 /etc/sudoers.d/99-$SAFE_USERNAME +EOF + fi + + sudo chroot "$ROOTFS_DIR" /bin/bash <> "$LOGFILE" 2>&1 +systemctl enable ssh +USER_UID=\$(id -u $USERNAME) +USER_GID=\$(id -g $USERNAME) +sed -i '/tmpfs \/home tmpfs/d' /etc/fstab +echo "tmpfs /home/$USERNAME tmpfs defaults,size=512M,uid=\$USER_UID,gid=\$USER_GID,mode=0755 0 0" >> /etc/fstab +mkdir -p /etc/ssh/sshd_config.d +if [[ "$ALLOW_SSH_PASSWORD" == "true" ]]; then + cat > /etc/ssh/sshd_config.d/60-build-settings.conf < /etc/ssh/sshd_config.d/60-build-settings.conf < /etc/tmpfiles.d/user-home.conf <> '$ROOTFS_DIR/etc/tmpfiles.d/user-home.conf'" <> "$LOGFILE" 2>&1 + fi + + log_progress " Copying overlay files preserving structure..." + sudo rsync -av --exclude='etc' --exclude='home' "$ROOTFS_OVERLAY/" "$ROOTFS_DIR/" >> "$LOGFILE" 2>&1 + fi +} + +cleanup_chroot_mounts() { + log_progress " Unmounting chroot filesystems..." + sudo umount "$ROOTFS_DIR/dev" 2>/dev/null || true + sudo umount "$ROOTFS_DIR/sys" 2>/dev/null || true + sudo umount "$ROOTFS_DIR/proc" 2>/dev/null || true +} + +# Compress the rootfs into a squashfs file +build_squashfs() { + log_progress " Compressing rootfs with xz compression..." + mksquashfs "$ROOTFS_DIR" "$SQUASHFS" -comp xz -all-root 2>&1 +} + +# Calculate the integrity data for the squashfs filesystem using dm-verity +generate_verity_hash() { + log_progress " Computing SHA256 hash tree for integrity verification..." + veritysetup format "$SQUASHFS" "$HASHFILE" --data-block-size=4096 --hash-block-size=4096 --hash sha256 >> "$BUILD_DIR/verity.log" 2>&1 + + detect_kernel + + ROOT_HASH=$(grep "Root hash:" "$BUILD_DIR/verity.log" | awk '{print $3}') + log_progress " Root hash: $ROOT_HASH" +} + +# Find the Azure Linux kernel that was installed in the rootfs +detect_kernel() { + log_progress " Detecting kernel in rootfs..." + ROOTFS_KERNEL=$(find "$ROOTFS_DIR/boot" -name "vmlinuz-*-azure" | head -1) + if [[ -n "$ROOTFS_KERNEL" ]]; then + AZURE_KERNEL_VERSION=$(basename "$ROOTFS_KERNEL" | sed 's/vmlinuz-//') + log_progress " Found Azure kernel: $AZURE_KERNEL_VERSION" + KERNEL="$ROOTFS_KERNEL" + log_progress " Using Azure kernel from rootfs: $KERNEL" + + if [[ -d "$ROOTFS_DIR/lib/modules/$AZURE_KERNEL_VERSION" ]]; then + log_progress " Azure kernel modules found in rootfs" + else + log_progress " Warning: No modules found for Azure kernel $AZURE_KERNEL_VERSION" + fi + + KERNEL_VERSION_FOR_BUILD="$AZURE_KERNEL_VERSION" + else + log_progress " No Azure kernel found, cannot continue" + exit 1 + fi +} + +# Create the raw disk image and partitions +create_disk_image() { + log_progress " Creating 8GB raw disk image..." + qemu-img create -f raw "$RAW_IMG" 8G >> "$LOGFILE" 2>&1 + + calculate_partitions + create_partition_table + attach_loop_device + format_and_write_partitions +} + +calculate_partitions() { + log_progress " Calculating partition layout..." + + # Define the size of the disk as 8GB + SIZE_BYTES=$((8 * 1024 * 1024 * 1024)) + SIZE_MB=$((SIZE_BYTES / 1024 / 1024)) + # End of the EFI partition in MB + EFI_END=201 + # End of the rootfs partition in MB + ROOT_END=$((SIZE_MB - 512)) + # Range for the hash partition in MB + HASH_START=$ROOT_END + HASH_END=$((SIZE_MB - 1)) + log_progress " EFI partition: 1MB - ${EFI_END}MB" + log_progress " Root partition: ${EFI_END}MB - ${ROOT_END}MB" + log_progress " Hash partition: ${HASH_START}MB - ${HASH_END}MB" +} + +create_partition_table() { + log_progress " Creating GPT partition table..." + sudo parted "$RAW_IMG" --script mklabel gpt >> "$LOGFILE" 2>&1 + sudo parted "$RAW_IMG" --script mkpart EFI fat32 1MiB ${EFI_END}MiB >> "$LOGFILE" 2>&1 + sudo parted "$RAW_IMG" --script set 1 boot on >> "$LOGFILE" 2>&1 + sudo parted "$RAW_IMG" --script mkpart squashfs ext4 ${EFI_END}MiB ${ROOT_END}MiB >> "$LOGFILE" 2>&1 + sudo parted "$RAW_IMG" --script mkpart verityhash ext4 ${HASH_START}MiB ${HASH_END}MiB >> "$LOGFILE" 2>&1 +} + +attach_loop_device() { + log_progress " Attaching loop device with partition support..." + LOOP=$(sudo losetup --find --show --partscan "$RAW_IMG") + log_progress " Loop device: $LOOP" + + sleep 2 + + if [[ ! -b "${LOOP}p1" ]]; then + log_progress " Forcing partition scan..." + sudo partx -u "$LOOP" >> "$LOGFILE" 2>&1 || true + sleep 2 + fi + + if [[ ! -b "${LOOP}p1" ]] || [[ ! -b "${LOOP}p2" ]] || [[ ! -b "${LOOP}p3" ]]; then + log_progress " ERROR: Failed to create partition devices" + log_progress " Available devices:" + ls -la /dev/loop* >> "$LOGFILE" 2>&1 + sudo losetup -d "$LOOP" >> "$LOGFILE" 2>&1 || true + exit 1 + fi + + EFI_DEV="${LOOP}p1" + ROOT_DEV="${LOOP}p2" + HASH_DEV="${LOOP}p3" +} + +format_and_write_partitions() { + log_progress " Formatting EFI partition..." + sudo mkfs.vfat "$EFI_DEV" >> "$LOGFILE" 2>&1 + + log_progress " Writing squashfs to root partition..." + sudo dd if="$SQUASHFS" of="$ROOT_DEV" bs=4M conv=notrunc,fsync status=none 2>> "$LOGFILE" + sudo sync + + log_progress " Writing hash tree to hash partition..." + sudo dd if="$HASHFILE" of="$HASH_DEV" bs=4M conv=notrunc,fsync status=none 2>> "$LOGFILE" + sudo sync +} + +# Configure the kernel command line +configure_boot_parameters() { + ROOT_UUID=$(blkid -s PARTUUID -o value "$ROOT_DEV") + HASH_UUID=$(blkid -s PARTUUID -o value "$HASH_DEV") + + log_progress " Creating kernel command line..." + cat > "$CMDLINE" <> "$LOGFILE" 2>&1 + INITRD_SIZE=$(du -h "$INITRD" | cut -f1) + + log_progress " Building Unified Kernel Image (UKI)..." + sudo ukify build \ + --stub="$STUB" \ + --linux="$KERNEL" \ + --initrd="$INITRD" \ + --cmdline=@"$CMDLINE" \ + --os-release="$OS_RELEASE" \ + --uname="$KERNEL_VERSION_FOR_BUILD" \ + --measure \ + --output="$UKI" > "$BUILD_DIR/ukify_output.txt" 2>&1 + + # Append ukify output to log and save for PCR11 extraction + cat "$BUILD_DIR/ukify_output.txt" >> "$LOGFILE" + + UKI_SIZE=$(du -h "$UKI" | cut -f1) + + log_progress " Installing UKI to EFI partition..." + sudo mount "$EFI_DEV" /mnt + sudo mkdir -p /mnt/EFI/Linux + sudo cp "$UKI" /mnt/EFI/Linux/ + sudo mkdir -p /mnt/EFI/Boot + sudo cp "$UKI" /mnt/EFI/Boot/BootX64.efi + sudo umount /mnt + + log_progress " Cleaning up loop device..." + sudo losetup -d "$LOOP" +} + +# Convert the raw image into a vhdx +finalize_vhdx() { + log_progress " Converting raw image to VHDX format..." + qemu-img convert -f raw -O vhdx "$RAW_IMG" "$VHDX_IMG" >> "$LOGFILE" 2>&1 + VHDX_SIZE=$(du -h "$VHDX_IMG" | cut -f1) + + log_progress " Calculating expected TPM PCR values..." + + # Calculate PCR4 (boot loader measurements) + ./scripts/calc_pcr4.sh "$UKI" > "$BUILD_DIR/pcr4.txt" 2>> "$LOGFILE" + + # Calculate PCR11 (UKI section measurements) + ./scripts/calc_pcr11.sh "$UKI" > "$BUILD_DIR/pcr11.txt" 2>> "$LOGFILE" + + # Combine both PCR4 and PCR11 into a single file + { + echo "==========================================" + echo "Expected TPM PCR Values" + echo "==========================================" + echo "" + echo "PCR 4 - Boot Loader Code and Configuration" + echo "-------------------------------------------" + tail -n 7 "$BUILD_DIR/pcr4.txt" + echo "" + echo "PCR 11 - UKI Section Measurements" + echo "-------------------------------------------" + tail -n 7 "$BUILD_DIR/pcr11.txt" + } > "$OUT_DIR/calculated_pcrs.txt" +} + +# Parse command-line arguments +parse_arguments() { + USERNAME="" + VHDX_FILENAME="" + ADDITIONAL_PACKAGES="" + ROOTFS_OVERLAY="" + PACKAGE_DIR="" + SSH_PUBLIC_KEY="" + SET_PASSWORD="false" + PASSWORDLESS_SUDO="false" + INSIDER_FAST="false" + ALLOW_SSH_PASSWORD="false" + ALLOW_SERIAL_CONSOLE="false" + VERBOSE="false" + + while [[ $# -gt 0 ]]; do + case "$1" in + --username) + USERNAME="$2" + shift 2 + ;; + --image) + VHDX_FILENAME="$2" + shift 2 + ;; + --packages) + ADDITIONAL_PACKAGES="$2" + shift 2 + ;; + --ssh-key) + SSH_PUBLIC_KEY="$2" + shift 2 + ;; + --rootfs-overlay) + ROOTFS_OVERLAY="$2" + shift 2 + ;; + --package-dir) + PACKAGE_DIR="$2" + shift 2 + ;; + --password) + SET_PASSWORD="true" + shift + ;; + --passwordless-sudo) + PASSWORDLESS_SUDO="true" + shift + ;; + --insiders-fast) + INSIDER_FAST="true" + shift + ;; + --allow-ssh-password) + ALLOW_SSH_PASSWORD="true" + shift + ;; + --allow-serial-console) + ALLOW_SERIAL_CONSOLE="true" + shift + ;; + --password-hash) + PASSWORD_HASH="$2" + shift 2 + ;; + --verbose-output) + VERBOSE="true" + shift + ;; + *) + echo "Unknown option: $1" + usage + ;; + esac + done + + # Validate required arguments + if [[ -z "$USERNAME" || -z "$VHDX_FILENAME" ]]; then + usage + fi + if [[ "$SET_PASSWORD" == "true" && -n "${PASSWORD_HASH:-}" ]]; then + echo "Error: --password and --password-hash are mutually exclusive." + exit 1 + fi +} + +# Prompt for user password securely +prompt_for_password() { + echo -n "Enter password for user '$USERNAME': " + read -s PASSWORD + echo + echo -n "Confirm password: " + read -s PASSWORD_CONFIRM + echo + + if [[ "$PASSWORD" != "$PASSWORD_CONFIRM" ]]; then + echo "Error: Passwords do not match" + exit 1 + fi + + if [[ -z "$PASSWORD" ]]; then + echo "Error: Password cannot be empty" + exit 1 + fi + + if [[ "$PASSWORD" == *"'"* ]]; then + echo "Error: Password must not contain single quotes (')" + exit 1 + fi +} + +# ============================================================================ +# Main Script +# ============================================================================ + +parse_arguments "$@" + +BUILD_DIR="./build" +OUT_DIR="./out" +ROOTFS_DIR="./rootfs" +RAW_IMG="$BUILD_DIR/${VHDX_FILENAME%.vhdx}.raw" +VHDX_IMG="$OUT_DIR/$VHDX_FILENAME" +SQUASHFS="$BUILD_DIR/rootfs.squashfs" +HASHFILE="$BUILD_DIR/rootfs.hash" +UKI="$BUILD_DIR/uki.efi" +CMDLINE="$BUILD_DIR/cmdline.txt" +INITRD="$BUILD_DIR/initrd-verity.img" +KERNEL="/boot/vmlinuz-$(uname -r)" +OS_RELEASE="/etc/os-release" +STUB="$ROOTFS_DIR/usr/lib/systemd/boot/efi/linuxx64.efi.stub" +LOGFILE="$BUILD_DIR/build.log" + +# Create build directory and log file +rm -rf "$BUILD_DIR" 2>/dev/null || true +mkdir -p "$BUILD_DIR" +mkdir -p "$OUT_DIR" + +# In verbose mode, tee all output (stdout + stderr) to both the console and the log +# file. LOGFILE is then changed to /dev/stdout so every ">> $LOGFILE" redirect also +# flows through the same tee pipe rather than writing directly to the file. +if [[ "$VERBOSE" == "true" ]]; then + exec > >(tee -a "$LOGFILE") 2>&1 + LOGFILE="/dev/stdout" + log_progress() { echo "$@"; } +fi + +log_progress "==========================================" +log_progress "CVM image build started" +log_progress "==========================================" +log_progress "Target VHDX: $VHDX_FILENAME" +log_progress "Username: $USERNAME" +if [[ -n "$ADDITIONAL_PACKAGES" ]]; then + log_progress "Additional packages: $ADDITIONAL_PACKAGES" +fi +if [[ -n "$ROOTFS_OVERLAY" ]]; then + log_progress "Rootfs overlay: $ROOTFS_OVERLAY" +fi +if [[ -n "$PACKAGE_DIR" ]]; then + log_progress "Package directory: $PACKAGE_DIR" +fi +log_progress "Build log: $BUILD_DIR/build.log" +log_progress "==========================================" +log_progress "" + +if [[ -n "${PASSWORD_HASH:-}" ]]; then + log_progress "Using provided password hash for user account" +elif [[ "$SET_PASSWORD" == "true" ]]; then + prompt_for_password +else + PASSWORD="" + if [[ "$ALLOW_SSH_PASSWORD" == "true" ]]; then + echo "Error: --allow-ssh-password requires --password or --password-hash to be set." + exit 1 + fi + if [[ "$ALLOW_SERIAL_CONSOLE" == "true" ]]; then + echo "Error: --allow-serial-console requires --password or --password-hash to be set." + exit 1 + fi + if [[ -z "$SSH_PUBLIC_KEY" ]]; then + echo "WARNING: Building VM with no password and no SSH key." + echo " You will NOT be able to log in to this VM!" + fi +fi + +log_progress ">>> Installing dependencies..." +install_dependencies + +log_progress ">>> Cleaning build environment..." +prepare_build_environment + +log_progress ">>> Preparing rootfs..." +create_ubuntu_rootfs +build_squashfs + +log_progress ">>> Generating integrity data..." +generate_verity_hash + +log_progress ">>> Preparing raw disk image..." +create_disk_image + +log_progress ">>> Generating kernel command line..." +configure_boot_parameters + +log_progress ">>> Building initramfs and UKI..." +build_initramfs_and_uki + +log_progress ">>> Converting raw disk to vhdx..." +finalize_vhdx + +# Output final summary +log_progress "" +log_progress "==========================================" +log_progress "Build Complete" +log_progress "==========================================" +log_progress "VHDX image: $VHDX_IMG ($VHDX_SIZE)" +log_progress "Build artifacts in: $BUILD_DIR/" +log_progress "DM-verity root hash: $ROOT_HASH" +log_progress "" +if [[ "$VERBOSE" == "true" ]]; then + cat "$OUT_DIR/calculated_pcrs.txt" +else + cat "$OUT_DIR/calculated_pcrs.txt" | tee -a "$LOGFILE" +fi diff --git a/ConfidentialComputing/CvmImageBuilder/build-docker.ps1 b/ConfidentialComputing/CvmImageBuilder/build-docker.ps1 new file mode 100644 index 00000000..e56e3c05 --- /dev/null +++ b/ConfidentialComputing/CvmImageBuilder/build-docker.ps1 @@ -0,0 +1,121 @@ +# +# Copyright (c) Microsoft Corporation. All rights reserved. +# +# Description: +# PowerShell script to build CVM images using Docker via WSL2 +# Note: WSL integration must be enabled in Docker Desktop settings: +# Docker Desktop > Settings > Resources > WSL Integration +# Toggle ON for your distro, then click 'Apply & Restart' +# +# + +param( + [Parameter(Mandatory=$true)] + [string]$Username, + + [Parameter(Mandatory=$true)] + [string]$Image, + + [string]$Packages, + [string]$RootfsOverlay, + [string]$PackageDir, + [string]$SshKey, + [switch]$Password, + [string]$PasswordHash, + [switch]$PasswordlessSudo, + [switch]$InsidersFast, + [switch]$AllowSshPassword, + [switch]$AllowSerialConsole, + [switch]$VerboseOutput, + [string]$WslDistro, + [switch]$DockerRebuild +) + +function RunInWSL { + param([Parameter(ValueFromRemainingArguments=$true)][string[]]$WslArgs) + $cmdArgs = [System.Collections.Generic.List[string]]::new() + if ($WslDistro) { + $cmdArgs.Add('-d') + $cmdArgs.Add($WslDistro) + } + $cmdArgs.AddRange([string[]]$WslArgs) + & wsl $cmdArgs +} + +Write-Host "Docker Desktop with WSL2 is required for this build process" -ForegroundColor Yellow +Write-Host "" + +# Check if Docker is running +try { + docker info | Out-Null +} catch { + Write-Error "Docker is not running. Please start Docker Desktop." + exit 1 +} + +# Check if WSL2 backend is being used +$dockerInfo = docker info --format "{{.OSType}}" +if ($dockerInfo -ne "linux") { + Write-Error "Docker must be configured to use WSL2 backend (Linux containers)" + Write-Host "To enable WSL2 backend:" -ForegroundColor Yellow + Write-Host "1. Open Docker Desktop" -ForegroundColor Yellow + Write-Host "2. Go to Settings > General" -ForegroundColor Yellow + Write-Host "3. Enable 'Use the WSL 2 based engine'" -ForegroundColor Yellow + exit 1 +} + + +$wslos = RunInWSL bash -c 'cat /etc/os-release' +$prettyName = ($wslos | Where-Object { $_ -match '^PRETTY_NAME=' }) -replace '^PRETTY_NAME="?([^"]*)"?$', '$1' +Write-Host "WSL OS: $prettyName" -ForegroundColor Cyan +# Convert Windows paths to WSL paths if needed +$scriptDir = $PSScriptRoot +$wslScriptDir = RunInWSL wslpath -a "'$scriptDir'" + +Write-Host "Script directory (Windows): $scriptDir" -ForegroundColor Cyan +Write-Host "Script directory (WSL): $wslScriptDir" -ForegroundColor Cyan +Write-Host "" + +# Build Docker arguments +$dockerArgs = @( + "--username", $Username, + "--image", $Image +) + +if ($Packages) { $dockerArgs += "--packages", $Packages } +if ($RootfsOverlay) { $dockerArgs += "--rootfs-overlay", $RootfsOverlay } +if ($PackageDir) { $dockerArgs += "--package-dir", $PackageDir } +if ($SshKey) { $dockerArgs += "--ssh-key", $SshKey } +if ($Password) { $dockerArgs += "--password" } +if ($PasswordHash) { $dockerArgs += "--password-hash", $PasswordHash } +if ($PasswordlessSudo) { $dockerArgs += "--passwordless-sudo" } +if ($InsidersFast) { $dockerArgs += "--insiders-fast" } +if ($AllowSshPassword) { $dockerArgs += "--allow-ssh-password" } +if ($AllowSerialConsole) { $dockerArgs += "--allow-serial-console" } +if ($VerboseOutput) { $dockerArgs += "--verbose-output" } + +# Prepare docker-build.sh arguments +$bashArgs = $dockerArgs -join " " +if ($DockerRebuild) { + $bashArgs = "--docker-rebuild $bashArgs" +} + +Write-Host "Executing build via WSL2..." -ForegroundColor Green +Write-Host "Arguments: $bashArgs" -ForegroundColor Cyan +Write-Host "" + +# Execute docker-build.sh via WSL +RunInWSL bash -c "cd '$wslScriptDir' && ./docker-build.sh $bashArgs" + +$exitCode = $LASTEXITCODE + +if ($exitCode -eq 0) { + Write-Host "" + Write-Host "Build completed successfully!" -ForegroundColor Green + Write-Host "Output VHDX location (Windows): $scriptDir\out\$Image" -ForegroundColor Cyan +} else { + Write-Host "" + Write-Error "Build failed with exit code: $exitCode" +} + +exit $exitCode diff --git a/ConfidentialComputing/CvmImageBuilder/docker-build.sh b/ConfidentialComputing/CvmImageBuilder/docker-build.sh new file mode 100644 index 00000000..748c843e --- /dev/null +++ b/ConfidentialComputing/CvmImageBuilder/docker-build.sh @@ -0,0 +1,223 @@ +#!/usr/bin/env bash +# +# Copyright (c) Microsoft Corporation. All rights reserved. +# +# Description: +# Docker wrapper script to build CVM images +# +set -euo pipefail + +# Script to run the CVM image build in a Docker container + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +IMAGE_NAME="cvm-image-builder" +IMAGE_TAG="latest" + +usage() { + echo "Docker wrapper for build-cvm-image.sh" + echo "" + echo "This script builds a Docker container and runs the CVM image build process inside it." + echo "All arguments are passed through to the build script inside the container." + echo "" + echo "Usage: $0 [build_script_arguments...]" + echo "" + echo "Examples:" + echo " $0 --username user --image vm.vhdx" + echo " $0 --username user --image vm.vhdx --ssh-key ~/.ssh/id_rsa.pub" + echo " $0 --username user --image vm.vhdx --ssh-key ~/.ssh/id_rsa.pub --passwordless-sudo" + echo " $0 --username user --image vm.vhdx --allow-ssh-password" + echo " $0 --username user --image vm.vhdx --allow-serial-console" + echo " $0 --username user --image vm.vhdx --password-hash " + echo " $0 --username user --image vm.vhdx --package-dir ./local-pkgs" + echo "" + echo "Additional Docker options:" + echo " --docker-rebuild Force rebuild of the Docker image" + echo " --docker-help Show this help message" + echo "" + echo "Build script options:" + echo " --insiders-fast Enable packages.microsoft.com insiders-fast apt repo" + echo " --verbose-output Print the full build log to the console instead of just the summary" + exit 1 +} + +# Parse Docker-specific arguments first +REBUILD_IMAGE=false +FILTERED_ARGS=() +while [[ $# -gt 0 ]]; do + case "$1" in + --docker-help) + usage + ;; + --docker-rebuild) + REBUILD_IMAGE=true + shift + ;; + *) + FILTERED_ARGS+=("$1") + shift + ;; + esac +done + +# Restore filtered arguments +set -- "${FILTERED_ARGS[@]}" + +# Check if Docker is available +if ! command -v docker &> /dev/null; then + echo "Error: Docker is not installed or not in PATH" + echo "Please install Docker first: https://docs.docker.com/engine/install/" + exit 1 +fi + +# Check if Docker daemon is running +if ! docker info &> /dev/null; then + echo "Error: Docker daemon is not running" + echo "Please start Docker first" + exit 1 +fi + +# Check if image exists and needs rebuilding +IMAGE_EXISTS=$(docker images -q "${IMAGE_NAME}:${IMAGE_TAG}" 2>/dev/null || true) + +if [[ -z "$IMAGE_EXISTS" ]] || [[ "$REBUILD_IMAGE" == true ]]; then + echo "Building Docker image: ${IMAGE_NAME}:${IMAGE_TAG}" + echo "This may take a few minutes..." + + docker build \ + --tag "${IMAGE_NAME}:${IMAGE_TAG}" \ + --file "${SCRIPT_DIR}/Dockerfile" \ + "${SCRIPT_DIR}" || { + echo "Error: Failed to build Docker image" + exit 1 + } + echo "✓ Docker image built successfully" +else + echo "Using existing Docker image: ${IMAGE_NAME}:${IMAGE_TAG}" + echo "Use --docker-rebuild to force rebuild" +fi + +# Ensure build and out directories exist on host +mkdir -p "${SCRIPT_DIR}/build" +mkdir -p "${SCRIPT_DIR}/out" + +echo "" +echo "Starting containerized build..." +echo "Build artifacts will be available in: ${SCRIPT_DIR}/build/" +echo "Output image will be available in: ${SCRIPT_DIR}/out/" +echo "Rootfs will be stored in Docker volume: cvm-build-rootfs" +echo "" + +# Set up Docker arguments +DOCKER_ARGS=( + "--rm" + "--privileged" + "--cap-add=SYS_ADMIN" + "--cap-add=MKNOD" + "--device=/dev/loop-control" + "--volume" "/dev:/dev" + "--volume" "${SCRIPT_DIR}/build:/workspace/build" + "--volume" "${SCRIPT_DIR}/out:/workspace/out" + "--volume" "${SCRIPT_DIR}/rootfs-files:/workspace/rootfs-files:ro" +) + +# Only request interactive TTY allocation when running in a terminal. +# CI environments often have no TTY and `docker run -t` will fail. +if [[ -t 0 && -t 1 ]]; then + DOCKER_ARGS+=("--interactive" "--tty") +fi + +# Add rootfs mount - either volume or bind mount +# Create volume if it doesn't exist +docker volume create cvm-build-rootfs >/dev/null 2>&1 || true +DOCKER_ARGS+=("--volume" "cvm-build-rootfs:/workspace/rootfs") + +# Process arguments and handle file mounts +CONTAINER_ARGS=() +ARGS=("$@") # Convert positional parameters to array +i=0 +while [[ $i -lt ${#ARGS[@]} ]]; do + arg="${ARGS[$i]}" + + case "$arg" in + --ssh-key) + # Next argument should be the file path + CONTAINER_ARGS+=("$arg") + i=$((i + 1)) + if [[ $i -lt ${#ARGS[@]} ]]; then + CONFIG_FILE="${ARGS[$i]}" + if [[ -f "$CONFIG_FILE" ]]; then + # Convert to absolute path + CONFIG_ABS=$(realpath "$CONFIG_FILE") + CONFIG_CONTAINER="/workspace/$(basename "$CONFIG_FILE")" + DOCKER_ARGS+=( + "--volume" "${CONFIG_ABS}:${CONFIG_CONTAINER}:ro" + ) + # Use container path in arguments + CONTAINER_ARGS+=("$CONFIG_CONTAINER") + else + echo "Error: Configuration file not found: $CONFIG_FILE" + exit 1 + fi + else + echo "Error: $arg requires a file path" + exit 1 + fi + ;; + --rootfs-overlay) + # Next argument should be the directory path + CONTAINER_ARGS+=("$arg") + i=$((i + 1)) + if [[ $i -lt ${#ARGS[@]} ]]; then + OVERLAY_DIR="${ARGS[$i]}" + if [[ -d "$OVERLAY_DIR" ]]; then + # Convert to absolute path + OVERLAY_ABS=$(realpath "$OVERLAY_DIR") + OVERLAY_CONTAINER="/workspace/$(basename "$OVERLAY_DIR")" + DOCKER_ARGS+=( + "--volume" "${OVERLAY_ABS}:${OVERLAY_CONTAINER}:ro" + ) + # Use container path in arguments + CONTAINER_ARGS+=("$OVERLAY_CONTAINER") + else + echo "Error: Rootfs overlay directory not found: $OVERLAY_DIR" + exit 1 + fi + else + echo "Error: --rootfs-overlay requires a directory path" + exit 1 + fi + ;; + --package-dir) + # Next argument should be the directory path containing .deb files + CONTAINER_ARGS+=("$arg") + i=$((i + 1)) + if [[ $i -lt ${#ARGS[@]} ]]; then + PKG_DIR="${ARGS[$i]}" + if [[ -d "$PKG_DIR" ]]; then + # Convert to absolute path + PKG_ABS=$(realpath "$PKG_DIR") + PKG_CONTAINER="/workspace/$(basename "$PKG_DIR")-packages" + DOCKER_ARGS+=( + "--volume" "${PKG_ABS}:${PKG_CONTAINER}:ro" + ) + # Use container path in arguments + CONTAINER_ARGS+=("$PKG_CONTAINER") + else + echo "Error: Package directory not found: $PKG_DIR" + exit 1 + fi + else + echo "Error: --package-dir requires a directory path" + exit 1 + fi + ;; + *) + # Regular argument, pass through as-is + CONTAINER_ARGS+=("$arg") + ;; + esac + i=$((i + 1)) +done + +# Run the Docker container +exec docker run "${DOCKER_ARGS[@]}" "${IMAGE_NAME}:${IMAGE_TAG}" "${CONTAINER_ARGS[@]}" diff --git a/ConfidentialComputing/CvmImageBuilder/rootfs-files/apply-cloud-init-config.service b/ConfidentialComputing/CvmImageBuilder/rootfs-files/apply-cloud-init-config.service new file mode 100644 index 00000000..e34850b8 --- /dev/null +++ b/ConfidentialComputing/CvmImageBuilder/rootfs-files/apply-cloud-init-config.service @@ -0,0 +1,14 @@ +[Unit] +Description=Apply cloud-init configuration from EFI partition +DefaultDependencies=no +After=local-fs.target etc-overlay.service +Before=cloud-init-local.service +Wants=cloud-init-local.service + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/apply-cloud-init-config.sh +RemainAfterExit=yes + +[Install] +WantedBy=sysinit.target diff --git a/ConfidentialComputing/CvmImageBuilder/rootfs-files/apply-cloud-init-config.sh b/ConfidentialComputing/CvmImageBuilder/rootfs-files/apply-cloud-init-config.sh new file mode 100644 index 00000000..75430f8d --- /dev/null +++ b/ConfidentialComputing/CvmImageBuilder/rootfs-files/apply-cloud-init-config.sh @@ -0,0 +1,120 @@ +#!/bin/bash +# +# Copyright (c) Microsoft Corporation. All rights reserved. +# +# Description: +# This script checks for cloud-init configuration on a mounted disk +# and copies it to the EFI system partition for persistent use across reboots. +# + +# Ensure log directory exists (may not exist early in boot) +mkdir -p /var/log + +LOGFILE="/var/log/apply-cloud-init-config.log" +exec > >(tee -a "$LOGFILE") 2>&1 + +echo "=== $(date) - Starting cloud-init config application ===" + +set -e + +EFI_MOUNT="/run/efi" +CLOUD_INIT_EFI="$EFI_MOUNT/EFI/cloud-init" +CLOUD_INIT_SEED="/var/lib/cloud/seed/nocloud-net" +CLOUD_INIT_MOUNT="/run/cloud-init-source" + +echo "Looking for cloud-init source device..." + +# Start by mounting the EFI partition - we will either copy the cloud-init config there +# or read existing config from there so it must always be mounted. +EFI_PARTITION=$(blkid -t TYPE=vfat -o device 2>/dev/null | head -1) +if [[ -n "$EFI_PARTITION" ]]; then + echo "Mounting EFI partition: $EFI_PARTITION at $EFI_MOUNT" + mkdir -p "$EFI_MOUNT" + if mount "$EFI_PARTITION" "$EFI_MOUNT" 2>&1; then + echo "Successfully mounted EFI partition" + else + echo "Failed to mount EFI partition" + exit 0 + fi +else + echo "No EFI partition found" + exit 0 +fi + +# Check for devices with CIDATA label. If this is the first boot then this should be present +# and contain the cloud-init configuration files. +for device in $(blkid -t LABEL=CIDATA -o device 2>/dev/null) $(blkid -t LABEL=cidata -o device 2>/dev/null); do + if [[ -b "$device" ]]; then + echo "Found cloud-init device with CIDATA label: $device (first boot). Copying cloud-init config to EFI system partition" + + mkdir -p "$CLOUD_INIT_MOUNT" + if ! mount -o ro "$device" "$CLOUD_INIT_MOUNT" 2>&1; then + echo "Failed to mount cloud-init device" + umount "$EFI_MOUNT" 2>&1 || true + exit 1 + fi + + echo "Copying configs from source to EFI system partition" + # Copy meta-data and network-config (user-data blocked for security) + if [[ -f "$CLOUD_INIT_MOUNT"/meta-data ]] && mountpoint -q "$EFI_MOUNT"; then + echo "Copying cloud-init configs to EFI partition" + mkdir -p "$CLOUD_INIT_EFI" + cp "$CLOUD_INIT_MOUNT"/meta-data "$CLOUD_INIT_EFI/" 2>&1 || true + cp "$CLOUD_INIT_MOUNT"/network-config "$CLOUD_INIT_EFI/" 2>&1 || true + sync + echo "Cloud-init configuration copied to EFI partition" + fi + + umount "$CLOUD_INIT_MOUNT" 2>&1 || echo "Failed to unmount cloud-init source" + break + fi +done + +# Here, we should have a mounted EFI partition that may contain cloud-init configs. +# Copy these files if found to the cloud-init seed directory. +echo "Attempting to apply cloud-init config from EFI partition to seed directory" +if [[ -d "$CLOUD_INIT_EFI" ]]; then + EFI_CONFIG_FILES=$(ls "$CLOUD_INIT_EFI"/meta-data 2>/dev/null || true) + if [[ -n "$EFI_CONFIG_FILES" ]]; then + echo "Found cloud-init configs on EFI, copying to seed directory" + + # Clear old cloud-init state to force re-run + rm -rf /var/lib/cloud/instances /var/lib/cloud/instance + + # Copy configs to cloud-init seed directory + mkdir -p "$CLOUD_INIT_SEED" + cp "$CLOUD_INIT_EFI"/meta-data "$CLOUD_INIT_SEED/" 2>&1 || true + cp "$CLOUD_INIT_EFI"/network-config "$CLOUD_INIT_SEED/" 2>&1 || true + # Create an empty user-data. Do not attempt to copy one from the EFI + # system partition as that is not a trusted location. + echo "#cloud-config" > "$CLOUD_INIT_SEED/user-data" + + # Enable cloud-init + rm -f /etc/cloud/cloud-init.disabled + touch /run/cloud-init-enabled + + echo "Cloud-init configuration applied and enabled" + else + echo "No cloud-init config files found on EFI partition" + fi +else + echo "cloud-init directory does not exist on EFI system partition" +fi + +echo "Unmounting EFI partition" +umount "$EFI_MOUNT" 2>&1 || echo "Failed to unmount EFI partition" + +# Eject and remove all CD/DVD devices to ensure no CDs remain attached +echo "Ejecting and removing all CD/DVD devices..." +for sr_dev in /sys/block/sr*; do + [[ -e "$sr_dev" ]] || continue + DEVICE_NAME=$(basename "$sr_dev") + echo "Ejecting /dev/$DEVICE_NAME" + eject "/dev/$DEVICE_NAME" 2>&1 || echo "Failed to eject /dev/$DEVICE_NAME" + if [[ -e "$sr_dev/device/delete" ]]; then + echo "Removing /dev/$DEVICE_NAME from system" + echo 1 > "$sr_dev/device/delete" 2>&1 || echo "Could not delete /dev/$DEVICE_NAME" + fi +done + +echo "=== Finished cloud-init config application ===" diff --git a/ConfidentialComputing/CvmImageBuilder/rootfs-files/cloud-init-hardened.yaml b/ConfidentialComputing/CvmImageBuilder/rootfs-files/cloud-init-hardened.yaml new file mode 100644 index 00000000..7b7642bc --- /dev/null +++ b/ConfidentialComputing/CvmImageBuilder/rootfs-files/cloud-init-hardened.yaml @@ -0,0 +1,35 @@ +# Cloud-init configuration for integrity-protected image - SECURITY HARDENED +# User-data is completely disabled - only metadata (hostname, network) is processed + +# Allow Azure, NoCloud (for local testing), or no datasource +datasource_list: [ Azure, NoCloud, None ] + +# SECURITY: Explicitly define ALL module stages to prevent defaults from running +# Empty lists = no modules run in that stage +cloud_init_modules: [] + +# Only allow hostname setting in config stage +cloud_config_modules: + - set_hostname + - update_hostname + - update_etc_hosts + +# No final stage modules +cloud_final_modules: [] + +# Block all user creation mechanisms +users: [] +disable_root: true +system_info: + default_user: + name: none + +# Prevent user-data from being processed at all +unverified_modules: [] + +# Network configuration from metadata is allowed +network: + config: enabled + +# Update /etc/hosts with hostname from metadata +manage_etc_hosts: true diff --git a/ConfidentialComputing/CvmImageBuilder/rootfs-files/cloud-init-local-override.conf b/ConfidentialComputing/CvmImageBuilder/rootfs-files/cloud-init-local-override.conf new file mode 100644 index 00000000..26f0ffbe --- /dev/null +++ b/ConfidentialComputing/CvmImageBuilder/rootfs-files/cloud-init-local-override.conf @@ -0,0 +1,4 @@ +[Unit] +# Override cloud-init-local conditions to run when we've set up the seed directory +ConditionPathExists= +ConditionPathExists=/run/cloud-init-enabled diff --git a/ConfidentialComputing/CvmImageBuilder/rootfs-files/emergency-disable.conf b/ConfidentialComputing/CvmImageBuilder/rootfs-files/emergency-disable.conf new file mode 100644 index 00000000..9de2edd7 --- /dev/null +++ b/ConfidentialComputing/CvmImageBuilder/rootfs-files/emergency-disable.conf @@ -0,0 +1,5 @@ +# Disable emergency shell and rescue mode for security +# Emergency shell would bypass normal authentication + +[Unit] +ConditionPathExists=/disable-emergency-shell-never-exists diff --git a/ConfidentialComputing/CvmImageBuilder/rootfs-files/etc-overlay.service b/ConfidentialComputing/CvmImageBuilder/rootfs-files/etc-overlay.service new file mode 100644 index 00000000..8a507147 --- /dev/null +++ b/ConfidentialComputing/CvmImageBuilder/rootfs-files/etc-overlay.service @@ -0,0 +1,14 @@ +[Unit] +Description=Setup writable /etc overlay on read-only root +DefaultDependencies=no +Conflicts=shutdown.target +Before=local-fs.target cloud-init-local.service cloud-init.service +After=systemd-remount-fs.service + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/usr/local/bin/setup-etc-overlay.sh + +[Install] +WantedBy=local-fs.target diff --git a/ConfidentialComputing/CvmImageBuilder/rootfs-files/setup-etc-overlay.sh b/ConfidentialComputing/CvmImageBuilder/rootfs-files/setup-etc-overlay.sh new file mode 100644 index 00000000..0ca7207e --- /dev/null +++ b/ConfidentialComputing/CvmImageBuilder/rootfs-files/setup-etc-overlay.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# +# Copyright (c) Microsoft Corporation. All rights reserved. +# +# Description: +# Script to set up an overlay filesystem for /etc to allow runtime modifications +# +set -e + +# Load overlay module +modprobe overlay + +# Create tmpfs for overlay +mkdir -p /run/etc-overlay +mount -t tmpfs -o size=128M tmpfs /run/etc-overlay +mkdir -p /run/etc-overlay/upper /run/etc-overlay/work + +# Mount overlay over /etc +mount -t overlay overlay \ + -o lowerdir=/etc,upperdir=/run/etc-overlay/upper,workdir=/run/etc-overlay/work \ + /etc + +# Copy seed files from rootfs-overlay's /etc into the runtime overlay +if [[ -d /usr/local/etc-overlay-seed ]]; then + echo "Copying /etc overlay seed files..." + cp -a /usr/local/etc-overlay-seed/* /etc/ 2>/dev/null || true +fi + +echo "/etc overlay configured successfully" diff --git a/ConfidentialComputing/CvmImageBuilder/rootfs-files/sources.list b/ConfidentialComputing/CvmImageBuilder/rootfs-files/sources.list new file mode 100644 index 00000000..5f999062 --- /dev/null +++ b/ConfidentialComputing/CvmImageBuilder/rootfs-files/sources.list @@ -0,0 +1,3 @@ +deb http://archive.ubuntu.com/ubuntu/ noble main restricted universe multiverse +deb http://archive.ubuntu.com/ubuntu/ noble-updates main restricted universe multiverse +deb http://archive.ubuntu.com/ubuntu/ noble-security main restricted universe multiverse diff --git a/ConfidentialComputing/CvmImageBuilder/rootfs-files/verity.conf b/ConfidentialComputing/CvmImageBuilder/rootfs-files/verity.conf new file mode 100644 index 00000000..03fed46e --- /dev/null +++ b/ConfidentialComputing/CvmImageBuilder/rootfs-files/verity.conf @@ -0,0 +1,3 @@ +[Unit] +Wants=systemd-veritysetup@root.service +Before=blockdev@dev-mapper-root.target diff --git a/ConfidentialComputing/CvmImageBuilder/scripts/calc_pcr11.sh b/ConfidentialComputing/CvmImageBuilder/scripts/calc_pcr11.sh new file mode 100644 index 00000000..a8bcaa0f --- /dev/null +++ b/ConfidentialComputing/CvmImageBuilder/scripts/calc_pcr11.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# +# Copyright (c) Microsoft Corporation. All rights reserved. +# +# Description: +# Script to calculate expected TPM PCR 11 value for a UKI boot +# PCR 11 tracks UKI section measurements during boot +# Each section contributes two extensions: hash(section_name) and hash(section_data) +# This ensures that all configuration used by the initramfs is measured into a combination +# of PCR4 and PCR11, with the bootloader components and kernel in PCR4 and the initramfs +# image, kernel command line and various other fields measured into PCR11. +# +# Note that this slightly differs from the standard UKI PCR11 measured boot calculation +# in that systemd events such as entering initrams and exiting initrams are not measured +# into PCR11. This is because these events do not extend the TCG log making it impossible +# for attestation services to correlate PCR11 with the TCG log when used. This does not +# affect the effectiveness of measured boot so long as there is no way for a user to +# run code between entering the initrams environment and leaving it - therefore the +# emergency recovery console must be disabled. +# + +# Calculates for SHA1, SHA256, and SHA384 TPM banks +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/pcr_helpers.sh" + +# UKI sections measured in order (as per UAPI spec) +UKI_SECTIONS=(".linux" ".osrel" ".cmdline" ".initrd" ".uname" ".sbat") + +usage() { + echo "Usage: $0 " + echo " Calculates the expected PCR 11 value after booting the given UKI image" + echo " Outputs values for SHA1, SHA256, and SHA384 TPM banks" + echo "" + echo " The UKI sections measured in order are:" + echo " ${UKI_SECTIONS[*]}" + exit 1 +} + +[[ $# -eq 1 ]] || usage +UKI_FILE="$1" + +if [[ ! -f "$UKI_FILE" ]]; then + echo "Error: UKI file not found: $UKI_FILE" >&2 + exit 2 +fi + +check_pcr_dependencies + +# Use a temporary directory for extracted sections +TEMP_DIR=$(mktemp -d) +trap "rm -rf $TEMP_DIR" EXIT + +echo "[*] Calculating expected PCR 11 values for: $UKI_FILE" +echo + +# Extract all sections from the UKI +echo "[*] Extracting UKI sections..." +declare -A SECTION_FILES +for section in "${UKI_SECTIONS[@]}"; do + # Remove leading dot for filename + section_name="${section#.}" + section_file="$TEMP_DIR/uki_section_${section_name}.bin" + + # Extract the section using -O binary --only-section + rm -f "$section_file" + if objcopy -O binary --only-section="${section}" "$UKI_FILE" "$section_file" 2>/dev/null; then + if [[ -s "$section_file" ]]; then + SECTION_FILES["$section"]="$section_file" + size=$(stat -c%s "$section_file" 2>/dev/null || stat -f%z "$section_file" 2>/dev/null) + echo " $section: extracted ($size bytes)" + else + echo " $section: not present" + rm -f "$section_file" + fi + else + echo " $section: not present" + rm -f "$section_file" + fi +done +echo + +# Calculate PCR 11 for each algorithm +for ALGO in sha1 sha256 sha384; do + echo "==========================================" + echo "Calculating PCR 11 for TPM bank: ${ALGO^^}" + echo "==========================================" + echo + + # Step 1: Initialize PCR 11 to all zeros + PCR11="$(get_zero_pcr "$ALGO")" + echo "[*] Initial PCR 11 (all zeros):" + echo " $PCR11" + echo + + step=1 + for section in "${UKI_SECTIONS[@]}"; do + if [[ -v SECTION_FILES["$section"] ]]; then + section_file="${SECTION_FILES[$section]}" + + echo "[$step] Processing section: $section" + + # Extend with hash of section name (including null terminator) + name_hash="$(hash_section_name "$section" "$ALGO")" + echo " ${ALGO^^}('$section\\0'): $name_hash" + PCR11="$(extend_pcr "$PCR11" "$name_hash" "$ALGO")" + echo " PCR 11 after name extend: $PCR11" + + # Extend with hash of section data + data_hash="$(hash_file "$section_file" "$ALGO")" + echo " ${ALGO^^}(section data): $data_hash" + PCR11="$(extend_pcr "$PCR11" "$data_hash" "$ALGO")" + echo " PCR 11 after data extend: $PCR11" + echo + + step=$((step + 1)) + fi + done + + # Store final value for summary + case "$ALGO" in + sha1) FINAL_SHA1="$PCR11" ;; + sha256) FINAL_SHA256="$PCR11" ;; + sha384) FINAL_SHA384="$PCR11" ;; + esac +done + +# Display summary +echo "==========================================" +echo "SUMMARY: Expected PCR 11 Values" +echo "==========================================" +echo +echo "SHA1: $FINAL_SHA1" +echo "SHA256: $FINAL_SHA256" +echo "SHA384: $FINAL_SHA384" +echo +SHA256_BASE64=$(echo -n "$FINAL_SHA256" | xxd -r -p | base64 -w 0) +echo "SHA256 (base64): $SHA256_BASE64" +echo +echo "==========================================" diff --git a/ConfidentialComputing/CvmImageBuilder/scripts/calc_pcr4.sh b/ConfidentialComputing/CvmImageBuilder/scripts/calc_pcr4.sh new file mode 100644 index 00000000..a2a89f5d --- /dev/null +++ b/ConfidentialComputing/CvmImageBuilder/scripts/calc_pcr4.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +# +# Copyright (c) Microsoft Corporation. All rights reserved. +# +# Description: +# Script to calculate expected TPM PCR 4 value for a UKI boot +# PCR 4 tracks boot loader code and boot attempts +# Calculates for SHA1, SHA256, and SHA384 TPM banks +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CALC_PE_HASH="$SCRIPT_DIR/calc_pe_hash.sh" +source "$SCRIPT_DIR/pcr_helpers.sh" + +usage() { + echo "Usage: $0 " + echo " Calculates the expected PCR 4 value after booting the given UKI image" + echo " Outputs values for SHA1, SHA256, and SHA384 TPM banks" + exit 1 +} + +[[ $# -eq 1 ]] || usage +UKI_FILE="$1" + +if [[ ! -f "$UKI_FILE" ]]; then + echo "Error: UKI file not found: $UKI_FILE" >&2 + exit 2 +fi + +check_pcr_dependencies + +if [[ ! -x "$CALC_PE_HASH" ]]; then + echo "Error: $CALC_PE_HASH not found or not executable" >&2 + exit 3 +fi + +BUILD_DIR="./build" +mkdir -p "$BUILD_DIR" + +echo "[*] Calculating expected PCR 4 values for: $UKI_FILE" +echo + +# Common data for all algorithms +EFI_STRING="Calling EFI Application from Boot Option" +ZERO_BYTES='\x00\x00\x00\x00' + +# Extract kernel once (used by all algorithms) +KERNEL_FILE="$BUILD_DIR/kernel.efi" +echo "[*] Extracting kernel from UKI to: $KERNEL_FILE" +objcopy --dump-section .linux="$KERNEL_FILE" "$UKI_FILE" /dev/null || true + +if [[ ! -f "$KERNEL_FILE" ]]; then + echo " Warning: Could not extract kernel from UKI (no .linux section?)" >&2 + HAVE_KERNEL=false +else + echo " Kernel extracted successfully" + HAVE_KERNEL=true +fi +echo + +# Calculate PCR 4 for each algorithm +for ALGO in sha1 sha256 sha384; do + echo "==========================================" + echo "Calculating PCR 4 for TPM bank: ${ALGO^^}" + echo "==========================================" + echo + + # Compute PE hashes with algorithm-specific authenticode hash + echo "[*] Computing PE authenticode hashes with ${ALGO^^}..." + UKI_HASH_HEX="$("$CALC_PE_HASH" -h "$ALGO" "$UKI_FILE")" + echo " UKI PE hash (${ALGO^^}): $UKI_HASH_HEX" + + if [[ "$HAVE_KERNEL" == true ]]; then + KERNEL_HASH_HEX="$("$CALC_PE_HASH" -h "$ALGO" "$KERNEL_FILE")" + echo " Kernel PE hash (${ALGO^^}): $KERNEL_HASH_HEX" + else + KERNEL_HASH_HEX="" + fi + echo + + # Convert PE hashes to lowercase for consistency + UKI_HASH_HEX="$(echo "$UKI_HASH_HEX" | tr 'A-F' 'a-f')" + [[ -n "$KERNEL_HASH_HEX" ]] && KERNEL_HASH_HEX="$(echo "$KERNEL_HASH_HEX" | tr 'A-F' 'a-f')" + + # Step 1: Initialize PCR 4 to all zeros + PCR4="$(get_zero_pcr "$ALGO")" + echo "[1] Initial PCR 4 (all zeros):" + echo " $PCR4" + echo + + # Step 2: Extend with hash of "Calling EFI Application from Boot Option" + HASH1="$(hash_data "$EFI_STRING" "$ALGO")" + echo "[2] ${ALGO^^}('$EFI_STRING'):" + echo " $HASH1" + PCR4="$(extend_pcr "$PCR4" "$HASH1" "$ALGO")" + echo " PCR 4 after extend:" + echo " $PCR4" + echo + + # Step 3: Extend with hash of 4 zero bytes + HASH2="$(hash_binary "$ZERO_BYTES" "$ALGO")" + echo "[3] ${ALGO^^}(4 zero bytes):" + echo " $HASH2" + PCR4="$(extend_pcr "$PCR4" "$HASH2" "$ALGO")" + echo " PCR 4 after extend:" + echo " $PCR4" + echo + + # Step 4: Extend with UKI PE hash (algorithm-specific authenticode hash) + echo "[4] Extending with UKI PE hash (${ALGO^^} authenticode)" + echo " PE hash: $UKI_HASH_HEX" + PCR4="$(extend_pcr "$PCR4" "$UKI_HASH_HEX" "$ALGO")" + echo " PCR 4 after extend:" + echo " $PCR4" + echo + + # Step 5: Extend with kernel PE hash if available + if [[ -n "$KERNEL_HASH_HEX" ]]; then + echo "[5] Extending with kernel PE hash (${ALGO^^} authenticode)" + echo " PE hash: $KERNEL_HASH_HEX" + PCR4="$(extend_pcr "$PCR4" "$KERNEL_HASH_HEX" "$ALGO")" + echo " PCR 4 after extend:" + echo " $PCR4" + echo + else + echo "[5] Skipping kernel PE hash (extraction failed)" + echo + fi + + # Store final value for summary + case "$ALGO" in + sha1) FINAL_SHA1="$PCR4" ;; + sha256) FINAL_SHA256="$PCR4" ;; + sha384) FINAL_SHA384="$PCR4" ;; + esac +done + +# Display summary +echo "==========================================" +echo "SUMMARY: Expected PCR 4 Values" +echo "==========================================" +echo +echo "SHA1: $FINAL_SHA1" +echo "SHA256: $FINAL_SHA256" +echo "SHA384: $FINAL_SHA384" +echo +SHA256_BASE64=$(echo -n "$FINAL_SHA256" | xxd -r -p | base64 -w 0) +echo "SHA256 (base64): $SHA256_BASE64" +echo +echo "==========================================" \ No newline at end of file diff --git a/ConfidentialComputing/CvmImageBuilder/scripts/calc_pe_hash.sh b/ConfidentialComputing/CvmImageBuilder/scripts/calc_pe_hash.sh new file mode 100644 index 00000000..f33e9f4b --- /dev/null +++ b/ConfidentialComputing/CvmImageBuilder/scripts/calc_pe_hash.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# +# Copyright (c) Microsoft Corporation. All rights reserved. +# +# Description: +# Script to calculate the hash of a PE file +# +# Usage: +# ./calc_pe_hash.sh path/to/file.exe +# ./calc_pe_hash.sh -h sha256 path/to/file.exe +# ./calc_pe_hash.sh --keep -h sha1 path/to/file.exe +# +# Prints only the "Calculated message digest" (hex, uppercase) to stdout. +# +set -euo pipefail + +show_usage() { + echo "Usage: $0 [-h {md5|sha1|sha2|sha256|sha384|sha512}] [--keep] " >&2 + echo " -h algorithm: Hash algorithm to use (default: sha256)" >&2 + echo " --keep: Keep temporary files for debugging" >&2 + exit 1 +} + +KEEP="0" +HASH_ALGO="sha256" + +# Parse arguments +while [[ $# -gt 0 ]]; do + case "$1" in + -h) + if [[ $# -lt 2 ]]; then + echo "Error: -h requires an argument" >&2 + show_usage + fi + HASH_ALGO="$2" + shift 2 + ;; + --keep) + KEEP="1" + shift + ;; + -*) + echo "Error: Unknown option: $1" >&2 + show_usage + ;; + *) + # This should be the input file + INPUT="$1" + shift + break + ;; + esac +done + +# Validate we have an input file +if [[ -z "${INPUT:-}" ]]; then + show_usage +fi + +if [[ ! -f "$INPUT" ]]; then + echo "Error: file not found: $INPUT" >&2 + exit 2 +fi + +# Check dependencies +command -v openssl >/dev/null 2>&1 || { echo "Error: openssl not found" >&2; exit 3; } +command -v osslsigncode >/dev/null 2>&1 || { echo "Error: osslsigncode not found" >&2; exit 3; } + +# Work dirs and temp files +BASE_DIR="build/dummykeys" +mkdir -p "$BASE_DIR" + +TMP_DIR="$(mktemp -d "${BASE_DIR}/tmp.XXXXXX")" +CERT="${TMP_DIR}/signing_cert.pem" +KEY="${TMP_DIR}/signing_key.pem" + +# Preserve the original extension for the signed file (best-effort) +ext="" +fname="$(basename -- "$INPUT")" +case "$fname" in + *.*) ext=".${fname##*.}";; +esac +SIGNED="${TMP_DIR}/signed${ext:-.bin}" + +cleanup() { + if [[ "$KEEP" != "1" ]]; then + rm -rf -- "$TMP_DIR" 2>/dev/null || true + fi +} +trap cleanup EXIT + +# Generate ephemeral key+cert (PEM) +# You can adjust key size/days/subject as needed. +openssl genrsa -out "$KEY" 2048 >/dev/null 2>&1 +openssl req -new -x509 -key "$KEY" -out "$CERT" -days 3650 -subj "/CN=Dummy Ephemeral Signing Cert" >/dev/null 2>&1 + +# Sign the file with osslsigncode using specified hash algorithm +# -n sets a display name; adjust as desired. Suppress stdout chatter. +osslsigncode sign \ + -certs "$CERT" \ + -key "$KEY" \ + -h "$HASH_ALGO" \ + -n "Temporary Signed Artifact" \ + -in "$INPUT" \ + -out "$SIGNED" \ + >/dev/null + +# Verify and extract the "Calculated message digest" line +# osslsigncode writes verification details to stdout; we parse the line. +VERIFY_OUT="$(osslsigncode verify "$SIGNED" 2>&1 || true)" + +# Example line: +# Calculated message digest : 0FE6204CCE786C5F1DCD65E0BA91CD58759EDB8CB8E1880CE44236AF00F241B +HASH="$(printf '%s\n' "$VERIFY_OUT" \ + | awk -F': ' '/^[[:space:]]*Calculated message digest[[:space:]]*:/ {print $2}' \ + | tr -d '[:space:]' \ + | tr 'a-f' 'A-F')" + +if [[ -z "$HASH" ]]; then + echo "Error: could not extract Calculated message digest from verify output." >&2 + # For debugging, uncomment the next line: + # printf 'Verify output:\n%s\n' "$VERIFY_OUT" >&2 + exit 4 +fi + +# Output only the hash +printf '%s\n' "$HASH" \ No newline at end of file diff --git a/ConfidentialComputing/CvmImageBuilder/scripts/pcr_helpers.sh b/ConfidentialComputing/CvmImageBuilder/scripts/pcr_helpers.sh new file mode 100644 index 00000000..da8c7458 --- /dev/null +++ b/ConfidentialComputing/CvmImageBuilder/scripts/pcr_helpers.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# +# Copyright (c) Microsoft Corporation. All rights reserved. +# +# Description: +# Shared helper functions for TPM PCR calculation scripts. +# Source this file from other scripts: +# source "$(dirname "${BASH_SOURCE[0]}")/pcr_helpers.sh" +# + +# Guard against double-sourcing +[[ -n "${_PCR_HELPERS_LOADED:-}" ]] && return 0 +_PCR_HELPERS_LOADED=1 + +# --------------------------------------------------------------------------- +# extend_pcr - PCR_new = Hash(PCR_old || event_data) +# +# Args: current_pcr_hex data_to_extend_hex algorithm +# --------------------------------------------------------------------------- +extend_pcr() { + local current="$1" + local data="$2" + local algo="$3" + + local combined + combined="$(printf '%s%s' "$current" "$data" | xxd -r -p | openssl dgst -"$algo" -binary | xxd -p -c 256)" + echo "$combined" +} + +# --------------------------------------------------------------------------- +# get_zero_pcr - Return an all-zeros PCR value (hex) for the given algorithm +# +# Args: algorithm (sha1 | sha256 | sha384) +# --------------------------------------------------------------------------- +get_zero_pcr() { + local algo="$1" + case "$algo" in + sha1) + echo "0000000000000000000000000000000000000000" # 20 bytes + ;; + sha256) + echo "0000000000000000000000000000000000000000000000000000000000000000" # 32 bytes + ;; + sha384) + echo "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" # 48 bytes + ;; + *) + echo "Error: Unknown algorithm $algo" >&2 + return 1 + ;; + esac +} + +# --------------------------------------------------------------------------- +# hash_data - Hash a plain string (no null terminator) +# +# Args: data_string algorithm +# --------------------------------------------------------------------------- +hash_data() { + local data="$1" + local algo="$2" + printf '%s' "$data" | openssl dgst -"$algo" -binary | xxd -p -c 256 +} + +# --------------------------------------------------------------------------- +# hash_binary - Hash literal bytes expressed as printf escape sequences +# (e.g. '\x00\x00\x00\x00') +# +# Args: escaped_bytes algorithm +# --------------------------------------------------------------------------- +hash_binary() { + local data="$1" + local algo="$2" + printf "$data" | openssl dgst -"$algo" -binary | xxd -p -c 256 +} + +# --------------------------------------------------------------------------- +# hash_section_name - Hash a section name string WITH a null terminator +# +# Args: name algorithm +# --------------------------------------------------------------------------- +hash_section_name() { + local name="$1" + local algo="$2" + printf '%s\0' "$name" | openssl dgst -"$algo" -binary | xxd -p -c 256 +} + +# --------------------------------------------------------------------------- +# hash_file - Hash the contents of a file +# +# Args: filepath algorithm +# --------------------------------------------------------------------------- +hash_file() { + local file="$1" + local algo="$2" + openssl dgst -"$algo" -binary "$file" | xxd -p -c 256 +} + +# --------------------------------------------------------------------------- +# check_pcr_dependencies - Verify required CLI tools are available +# --------------------------------------------------------------------------- +check_pcr_dependencies() { + for cmd in openssl objcopy xxd; do + command -v "$cmd" >/dev/null 2>&1 || { echo "Error: $cmd not found" >&2; exit 3; } + done +}