From ecb5b0f83bb4b0bddb6a1eb1517dfabb0eb7c499 Mon Sep 17 00:00:00 2001 From: HaRoLd <303926+HarryR@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:36:45 +0000 Subject: [PATCH] Adopt the stage0 standalone build/test pattern stage0 now lives in its own repo (github.com/lockboot/stage0) and is the canonical reference. This repo (the netboot UKI / stage1 track) still carried a full duplicate of stage0 and used the old per-repo Makefile style. Align it with stage0 so stage1, mkuki, and example-stage2 build the same way inside the shared workspace AND standalone on CI, ahead of renaming lockboot -> stage1. - Remove the vendored stage0: crates/stage0, crates/stage0-test-payload, tools/build-stage0/, the stage0-v* release track, and the stage0 make targets. stage0 is consumed externally now (like vaportpm from git): the chain test borrows ../stage0/build//boot.disk and the shared lockboot:harness image. - Makefile: adopt stage0's DOCKER_RUN plumbing (parent-workspace mount at /src, findmnt host-path translation, CI-keyed ephemeral caches, stat-based ownership), preserving the docker.sock + KVM passthrough the UKI build and chain test need. Add build//stage2 and a SIGN=1 ed25519 chain mode. Default SERVE_HOST to the tap IP so the guest's _stage2 hop needs no DNS. - CI: drop the stage0-v* track; build the UKI + example-stage2 and ship the leaf as a first-class artifact. Keep the uki-v* + ghcr runtime tracks. - Delete Dockerfile.dev (qemu harness borrowed from stage0 as lockboot:harness), the redundant tools/qemu-test/, and the per-repo .devcontainer/. - Excise stage0-domain artifacts duplicated/orphaned here: the cloud AMI/image publishers (tools/publish/{ec2,gcp,azure}), the Secure Boot key generator (tools/build-uki/keys), and the FAT-timestamp normalizer (disk/ESP repro - the UKI has no filesystem). Keep the UKI publisher, renamed to tools/publish.sh. - Dockerfile.build kept byte-identical to stage0's. Verified in-workspace with real QEMU + KVM: make x86_64 (UKI) and stage2 build; the full chain (stage0 -> UKI -> stage1 -> example-stage2 -> poweroff) passes in both sha256-pin and SIGN=1 ed25519 admission modes. Co-Authored-By: Claude Opus 4.8 --- .devcontainer/devcontainer.json | 28 -- .github/workflows/build.yml | 82 +--- .gitignore | 6 +- Cargo.toml | 5 - Dockerfile.dev | 30 -- Makefile | 435 ++++++++++---------- README.md | 46 +-- crates/example-stage2/README.md | 2 +- crates/mkuki/Cargo.toml | 5 +- crates/mkuki/src/sign.rs | 7 +- crates/stage0-test-payload/Cargo.lock | 272 ------------ crates/stage0-test-payload/Cargo.toml | 24 -- crates/stage0-test-payload/src/main.rs | 84 ---- crates/stage0/Cargo.lock | 351 ---------------- crates/stage0/Cargo.toml | 45 -- crates/stage0/README.md | 107 ----- crates/stage0/src/config.rs | 160 ------- crates/stage0/src/dns4.rs | 292 ------------- crates/stage0/src/embedded.rs | 86 ---- crates/stage0/src/http.rs | 146 ------- crates/stage0/src/main.rs | 234 ----------- crates/stage0/src/metadata.rs | 110 ----- crates/stage0/src/net.rs | 90 ---- crates/stage0/src/secauth.rs | 168 -------- crates/stage0/src/sig.rs | 33 -- crates/stage0/src/tcg2.rs | 70 ---- crates/stage0/src/tcp4.rs | 330 --------------- crates/stage0/src/timing.rs | 117 ------ tools/build-stage0/build.sh | 132 ------ tools/build-uki/build.sh | 2 +- tools/build-uki/keys/.gitignore | 5 - tools/build-uki/keys/Makefile | 16 - tools/build-uki/normalize-fat-timestamps.py | 94 ----- tools/{publish/upload-uki.sh => publish.sh} | 6 +- tools/publish/azure/.gitkeep | 0 tools/publish/ec2/create-ami.sh | 279 ------------- tools/publish/ec2/create-vmimport-role.sh | 92 ----- tools/publish/gcp/.gitignore | 4 - tools/publish/gcp/NOTES.md | 151 ------- tools/publish/gcp/create-image.sh | 284 ------------- tools/publish/gcp/get-console.sh | 94 ----- tools/publish/gcp/install-gcloud.sh | 170 -------- tools/publish/gcp/launch-instance.sh | 190 --------- tools/qemu-test/.gitignore | 11 - tools/qemu-test/Makefile | 19 - tools/qemu-test/boot.sh | 315 -------------- tools/qemu-test/provision-test-tpm.sh | 89 ---- 47 files changed, 253 insertions(+), 5065 deletions(-) delete mode 100644 .devcontainer/devcontainer.json delete mode 100644 Dockerfile.dev delete mode 100644 crates/stage0-test-payload/Cargo.lock delete mode 100644 crates/stage0-test-payload/Cargo.toml delete mode 100644 crates/stage0-test-payload/src/main.rs delete mode 100644 crates/stage0/Cargo.lock delete mode 100644 crates/stage0/Cargo.toml delete mode 100644 crates/stage0/README.md delete mode 100644 crates/stage0/src/config.rs delete mode 100644 crates/stage0/src/dns4.rs delete mode 100644 crates/stage0/src/embedded.rs delete mode 100644 crates/stage0/src/http.rs delete mode 100644 crates/stage0/src/main.rs delete mode 100644 crates/stage0/src/metadata.rs delete mode 100644 crates/stage0/src/net.rs delete mode 100644 crates/stage0/src/secauth.rs delete mode 100644 crates/stage0/src/sig.rs delete mode 100644 crates/stage0/src/tcg2.rs delete mode 100644 crates/stage0/src/tcp4.rs delete mode 100644 crates/stage0/src/timing.rs delete mode 100755 tools/build-stage0/build.sh delete mode 100644 tools/build-uki/keys/.gitignore delete mode 100644 tools/build-uki/keys/Makefile delete mode 100755 tools/build-uki/normalize-fat-timestamps.py rename tools/{publish/upload-uki.sh => publish.sh} (95%) delete mode 100644 tools/publish/azure/.gitkeep delete mode 100755 tools/publish/ec2/create-ami.sh delete mode 100755 tools/publish/ec2/create-vmimport-role.sh delete mode 100644 tools/publish/gcp/.gitignore delete mode 100644 tools/publish/gcp/NOTES.md delete mode 100755 tools/publish/gcp/create-image.sh delete mode 100755 tools/publish/gcp/get-console.sh delete mode 100755 tools/publish/gcp/install-gcloud.sh delete mode 100755 tools/publish/gcp/launch-instance.sh delete mode 100644 tools/qemu-test/.gitignore delete mode 100644 tools/qemu-test/Makefile delete mode 100755 tools/qemu-test/boot.sh delete mode 100755 tools/qemu-test/provision-test-tpm.sh diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json deleted file mode 100644 index a5f34d7..0000000 --- a/.devcontainer/devcontainer.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "Lock.Boot Dev", - "image": "lockboot:dev", - "mounts": [ - "source=${localWorkspaceFolder},target=/src,type=bind", - "source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind" - ], - "workspaceFolder": "/src", - "runArgs": [ - "--network=host", - "--user", "1000:1000", - "--group-add", "134", - "--privileged" - ], - "customizations": { - "vscode": { - "extensions": [ - "rust-lang.rust-analyzer", - "tamasfe.even-better-toml", - "serayuzgur.crates" - ], - "settings": { - "telemetry.telemetryLevel": "off" - } - } - }, - "remoteUser": "1000" -} \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d570e4b..65d2c12 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -3,10 +3,10 @@ name: build on: push: branches: [main] - # Two independent release tracks, gated by tag prefix: - # stage0-v* → the Secure Boot root of trust (db-signed, baked into the AMI) - # uki-v* → the netboot UKI (stage1), admitted by stage0 via sha256 + PCR 14 - tags: ['stage0-v*', 'uki-v*'] + # Release track gated by tag prefix: + # uki-v* → the netboot UKI (stage1), admitted by stage0 via sha256 + PCR 14 + # (The Secure Boot root of trust ships from the stage0 repo on its own stage0-v* track.) + tags: ['uki-v*'] pull_request: branches: [main] workflow_dispatch: @@ -22,16 +22,17 @@ jobs: - name: Checkout repository uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - # Build BOTH artifacts (ephemeral snakeoil keys) for every push/PR/tag so the - # whole chain is validated on each change. One `make` invocation builds the - # docker build-image once and produces: + # Build the netboot UKI and the example-stage2 leaf for every push/PR/tag. One + # `make` builds the docker build-image once (docker-build-base) and produces: # tools/build-uki//linux.efi (+ .sha256, snippet, os-release, busybox, stage1) - # tools/build-stage0//{boot.disk, BOOT*.EFI, efi-vars.*, *.cer, *.guid} - - name: Build UKI + stage0 for ${{ matrix.arch }} - run: make ${{ matrix.arch }} stage0-${{ matrix.arch }} - - # UKI (stage1) track: the netboot payload + its sha256 pin + the _stage1 - # snippet, plus busybox/stage1 for the stage1 runtime image. + # build//stage2 (the example leaf payload) + # No workspace / sibling repos needed: vaportpm is pulled from git, stage0 is not + # a build-time dependency (it netboots the UKI at runtime). + - name: Build UKI + example-stage2 for ${{ matrix.arch }} + run: make ${{ matrix.arch }} stage2-${{ matrix.arch }} + + # UKI (stage1) track: the netboot payload + its sha256 pin + the _stage1 snippet, + # busybox/stage1 for the runtime image, and the example-stage2 leaf. - name: Upload UKI artifacts for ${{ matrix.arch }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: @@ -43,60 +44,7 @@ jobs: tools/build-uki/${{ matrix.arch }}/os-release tools/build-uki/${{ matrix.arch }}/busybox tools/build-uki/${{ matrix.arch }}/stage1 - - # stage0 track: the firmware-admitted root + the public Secure Boot material - # (efi-vars for enrollment, *.cer/*.guid) needed to deploy a cloud image. - - name: Upload stage0 artifacts for ${{ matrix.arch }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: stage0-${{ matrix.arch }} - path: | - tools/build-stage0/${{ matrix.arch }}/boot.disk - tools/build-stage0/${{ matrix.arch }}/BOOT*.EFI - tools/build-stage0/${{ matrix.arch }}/efi-vars.* - tools/build-stage0/${{ matrix.arch }}/os-release - tools/build-stage0/${{ matrix.arch }}/*.cer - tools/build-stage0/${{ matrix.arch }}/*.guid - - # ---- stage0 release track (tag: stage0-v*) ------------------------------- - release-stage0: - runs-on: ubuntu-latest - needs: build - if: startsWith(github.ref, 'refs/tags/stage0-v') - permissions: - contents: write - id-token: write - attestations: write - - steps: - - name: Download stage0 artifact zips - env: - GH_TOKEN: ${{ github.token }} - run: | - ARTIFACTS=$(gh api repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts) - for arch in x86_64 aarch64; do - ARTIFACT_ID=$(echo "$ARTIFACTS" | jq -r ".artifacts[] | select(.name==\"stage0-$arch\") | .id") - gh api repos/${{ github.repository }}/actions/artifacts/${ARTIFACT_ID}/zip > stage0-${arch}.zip - done - - - name: Attest stage0 zips - uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0 - with: - subject-path: | - stage0-x86_64.zip - stage0-aarch64.zip - - - name: Create stage0 release - uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 - with: - tag_name: ${{ github.ref_name }} - files: | - stage0-x86_64.zip - stage0-aarch64.zip - draft: false - prerelease: true - generate_release_notes: true - make_latest: true + build/${{ matrix.arch }}/stage2 # ---- UKI (stage1) release track (tag: uki-v*) ---------------------------- release-uki: diff --git a/.gitignore b/.gitignore index b68bab4..72c7cf5 100644 --- a/.gitignore +++ b/.gitignore @@ -31,9 +31,9 @@ old .docker user-data.json user-data.stage0.json -# Ignore stage0 build outputs (x86_64/, aarch64/) and the release key (keys/), -# but keep tools/build-stage0/build.sh tracked. -tools/build-stage0/*/ +# Local build outputs: the example-stage2 leaf (build//stage2), chain +# serve-dirs, and the SIGN=1 ed25519 release key (build/keys/) all land here. +/build/ .bashrc .lesshst stage0-trace.* diff --git a/Cargo.toml b/Cargo.toml index eb498b8..c7aba25 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,5 @@ [workspace] members = ["crates/stage1", "crates/example-stage2", "crates/mkuki"] -# stage0 is a UEFI (no_std, *-unknown-uefi) application and cannot be built for -# the musl target this workspace defaults to. It is its own workspace so that -# `cargo build --all` (used to build the Linux stages) does not try to compile -# it for the host/musl target. -exclude = ["crates/stage0", "crates/stage0-test-payload"] resolver = "2" [workspace.package] diff --git a/Dockerfile.dev b/Dockerfile.dev deleted file mode 100644 index 94abb65..0000000 --- a/Dockerfile.dev +++ /dev/null @@ -1,30 +0,0 @@ -FROM lockboot:build - -RUN mkdir -p -m 755 /etc/apt/keyrings \ - && out=$(mktemp) && wget -nv -O$out https://cli.github.com/packages/githubcli-archive-keyring.gpg \ - && cat $out | tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null \ - && chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \ - && mkdir -p -m 755 /etc/apt/sources.list.d \ - && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null - -# Install development tools and Claude Code -RUN DEBIAN_FRONTEND=noninteractive apt-get -qq update && \ - DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - procps nano net-tools iputils-ping ca-certificates less xz-utils openssh-client bash-completion \ - docker.io \ - qemu-utils qemu-system-x86 qemu-system-arm \ - git \ - build-essential \ - gpg \ - iptables \ - iproute2 \ - dnsmasq \ - swtpm swtpm-tools \ - tpm2-tools xxd \ - tcpdump \ - gh jq python3-venv - -RUN groupadd -g 1000 vscode-dc && useradd -u 1000 -g 1000 -d /src -s /bin/bash vscode-dc - -# Development environment - keep same reproducible build settings from base -WORKDIR /src diff --git a/Makefile b/Makefile index 7ef0367..eefd232 100644 --- a/Makefile +++ b/Makefile @@ -1,155 +1,136 @@ -.PRECIOUS: tools/build-uki/keys/% tools/build-uki/% \ - tools/build-stage0/%/stage0.efi tools/build-stage0/%/payload.efi tools/build-stage0/%/stage2 tools/build-stage0/%/boot.disk +# stage1 - the netboot UKI (Linux stage1 + example leaf). Standalone build + test. +# +# make / make build build the netboot UKI linux.efi (both arches) +# make x86_64 | aarch64 build the UKI for one arch +# make stage2- build the example-stage2 leaf payload +# make test-chain- boot the whole chain under QEMU, using the EXTERNAL +# stage0 as the harness (../stage0/build//boot.disk) +# Knobs for test-chain: +# SIGN=1 admit the UKI by ed25519 signature instead of sha256 +# STAGE0_DIR=../stage0 where the sibling stage0 boot.disk is built +# STAGE0_BOOT_DISK= explicit boot.disk (out-of-workspace escape hatch) +# TRACE=1 capture the guest TCP stream to ./stage0-trace.pcap + +.PRECIOUS: tools/build-uki/% build/%/stage2 build/%/linux.efi.sig all: build -ARCHS=x86_64 aarch64 +ARCHS = x86_64 aarch64 +.PHONY: build build: $(ARCHS) +.PHONY: amd64 x86_64 arm64 aarch64 amd64 x86_64: tools/build-uki/x86_64/linux.efi arm64 aarch64: tools/build-uki/aarch64/linux.efi +# Reference production _stage1/_stage2 doc (served from S3 in prod). DEFAULT_STAGE2_URL = https://lockboot.s3.us-east-1.amazonaws.com/examples/stage2/user-data.json user-data.json: wget -O "$@" $(DEFAULT_STAGE2_URL) -# Docker image names -BUILD_IMAGE = lockboot:build -DEV_IMAGE = lockboot:dev +# ---- Docker images (shared lockboot family; built locally, never published) ---- +# BUILD_IMAGE compiles everything; HARNESS_IMAGE (from stage0) runs the qemu chain +# test. Both are built by the workspace `make image` from stage0 (the canonical +# Dockerfiles); a standalone CI clone builds BUILD_IMAGE itself via docker-build-base. +BUILD_IMAGE = lockboot:build +HARNESS_IMAGE = lockboot:harness RUNTIME_IMAGE ?= lockboot:latest -# Snakeoil Secure Boot keys, generated fresh per build (openssl + uuidgen). Run in -# the build container so a slim host / CI runner without those tools still works. -tools/build-uki/keys/%: docker-build-base - $(DOCKER_RUN) $(DOCKER_SAMEUSER) $(BUILD_IMAGE) make -C tools/build-uki/keys - -clean: - rm -rf tools/build-uki/x86_64/boot.disk tools/build-uki/x86_64/stage1 tools/build-uki/x86_64/tmp tools/build-uki/x86_64/*.img tools/build-uki/x86_64/*.efi tools/build-uki/x86_64/config-* tools/build-uki/x86_64/efi-vars.ovmf - rm -rf tools/build-uki/aarch64/boot.disk tools/build-uki/aarch64/stage1 tools/build-uki/aarch64/tmp tools/build-uki/aarch64/*.img tools/build-uki/aarch64/*.efi tools/build-uki/aarch64/config-* tools/build-uki/aarch64/efi-vars.ovmf - rm -rf tools/build-stage0/x86_64 tools/build-stage0/aarch64 - rm -f tools/build-uki/mkuki - -distclean: clean - $(MAKE) -C tools/build-uki clean - $(MAKE) -C tools/build-uki/keys clean - $(MAKE) -C tools/qemu-test clean - -# Download + extract UKI dependencies in the build container, which has the tools -# (rpm2cpio, cpio, curl, xz); the host / CI runner may not (e.g. act's slim image). -# Each sub-make writes into the mounted tools/build-uki/$*/ tree. -tools/build-uki/%/busybox: docker-build-base - $(DOCKER_RUN) $(DOCKER_SAMEUSER) $(BUILD_IMAGE) make -C tools/build-uki $*/busybox - -tools/build-uki/%/stub.efi: docker-build-base - $(DOCKER_RUN) $(DOCKER_SAMEUSER) $(BUILD_IMAGE) make -C tools/build-uki $*/stub.efi - -tools/build-uki/%/kernel-core.rpm: docker-build-base - $(DOCKER_RUN) $(DOCKER_SAMEUSER) $(BUILD_IMAGE) make -C tools/build-uki $*/kernel-core.rpm - -tools/build-uki/%/kernel-modules-core.rpm: docker-build-base - $(DOCKER_RUN) $(DOCKER_SAMEUSER) $(BUILD_IMAGE) make -C tools/build-uki $*/kernel-modules-core.rpm - -tools/qemu-test/%: - $(MAKE) -C tools/qemu-test $* - - -##################################################################### -# Docker build - +.PHONY: docker-build-base docker-build-base: docker build -f Dockerfile.build -t $(BUILD_IMAGE) . -# Build the dev image (extends build image) -docker-build-dev: docker-build-base - docker build -f Dockerfile.dev -t $(DEV_IMAGE) . - -# Alias for building both -docker-build: docker-build-dev - -docker-clean: - docker rmi $(BUILD_IMAGE) $(DEV_IMAGE) || true - -docker-dev: build run +# ---- Docker run plumbing (keep identical across repos; mirrors stage0/Makefile) ---- +# Own build artifacts by whoever owns the checkout, not the caller's euid. Under +# `gh act` the caller is root but the bind-mounted tree is still yours, so stat +# keeps output user-owned instead of trampling the project dir with root files. +USER_ID := $(shell stat -c %u .) +GROUP_ID := $(shell stat -c %g .) -docker-prune-system-wide: - docker image prune -f - docker system prune -f - docker system prune -f --volumes - docker system df - -# Setup buildx builder (run once) -docker-buildx-setup: - docker buildx create --name lockboot-builder --use || docker buildx use lockboot-builder - docker buildx inspect --bootstrap - -# Build runtime image for current platform only and load into Docker -docker-runtime: tools/build-uki/x86_64/busybox tools/build-uki/x86_64/stage1 tools/build-uki/aarch64/busybox tools/build-uki/aarch64/stage1 - docker buildx build \ - -f Dockerfile.runtime \ - -t $(RUNTIME_IMAGE) \ - --load \ - . - -# Build multi-arch and export to OCI tar (for local multi-arch without registry) -docker-runtime-oci: tools/build-uki/x86_64/busybox tools/build-uki/x86_64/stage1 tools/build-uki/aarch64/busybox tools/build-uki/aarch64/stage1 - docker buildx build \ - --platform linux/amd64,linux/arm64 \ - -f Dockerfile.runtime \ - -t $(RUNTIME_IMAGE) \ - --output type=oci,dest=lockboot.oci \ - . - -.PHONY: docker-buildx-setup docker-runtime docker-runtime-push docker-runtime-oci - - -##################################################################### -# Docker run - -USER_ID := $(shell id -u) -GROUP_ID := $(shell id -g) - -# Options for giving docker kvm access -KVM_GID := $(shell stat -c %g /dev/kvm 2>/dev/null || echo "") +KVM_GID := $(shell stat -c %g /dev/kvm 2>/dev/null || echo "") KVM_MOUNT := $(shell test -e /dev/kvm && echo "-v /dev/kvm:/dev/kvm") -DOCKER_GROUP_KVM := $(if $(KVM_GID),--group-add $(KVM_GID)) -DOCKER_OPT_KVM := $(DOCKER_GROUP_KVM) $(KVM_MOUNT) +DOCKER_OPT_KVM := $(if $(KVM_GID),--group-add $(KVM_GID)) $(KVM_MOUNT) -# Options for recursive docker -DOCKER_SOCK_GID := $(shell stat -c %g /var/run/docker.sock 2>/dev/null || echo "") +# Recursive-docker passthrough: the UKI rule and runtime-image build shell out to +# the HOST docker daemon (rootfs extraction / buildx), so forward the socket + gid. +DOCKER_SOCK_GID := $(shell stat -c %g /var/run/docker.sock 2>/dev/null || echo "") DOCKER_SOCK_MOUNT := $(shell test -e /var/run/docker.sock && echo "-v /var/run/docker.sock:/var/run/docker.sock") -DOCKER_GROUP_DOCKER := $(if $(DOCKER_SOCK_GID),--group-add $(DOCKER_SOCK_GID)) -DOCKER_OPT_DOCKER := $(DOCKER_SOCK_MOUNT) $(DOCKER_GROUP_DOCKER) +DOCKER_OPT_DOCKER := $(DOCKER_SOCK_MOUNT) $(if $(DOCKER_SOCK_GID),--group-add $(DOCKER_SOCK_GID)) DOCKER_SAMEUSER := -u $(USER_ID):$(GROUP_ID) -# Base docker run command with all common flags +# Host-path translation for docker-in-devcontainer. Inside the devcontainer /src is +# a host bind mount and the inner Docker talks to the HOST daemon, which cannot +# resolve /src/... paths; translate $(CURDIR) to the real host path (the bracketed +# subpath findmnt reports for the /src bind). On the host CURDIR is not under /src, +# so this is a pass-through and your workflow is unchanged. Keep identical across repos. +HOST_DIR := $(CURDIR) +ifneq ($(filter /src/%,$(CURDIR)),) + SRC_BIND := $(shell findmnt -fnro SOURCE --target /src 2>/dev/null | sed -n 's/.*\[\(.*\)\]$$/\1/p') + ifneq ($(SRC_BIND),) + HOST_DIR := $(SRC_BIND)$(CURDIR:/src%=%) + endif +endif + +# Mount the WORKSPACE (parent of this repo) at /src so builds reuse the shared +# workspace-level .cargo/.rustup (matching the devcontainer), instead of creating +# per-repo copies. The repo then lives at /src/$(REPO_NAME). +REPO_NAME := $(notdir $(HOST_DIR)) +HOST_WS := $(patsubst %/,%,$(dir $(HOST_DIR))) + +# Under CI / `gh act` (CI=true, runs as root) keep cargo/rustup caches ephemeral +# inside the container, so root-owned dirs never land in the bind-mounted project. +# Locally (no CI) the image's CARGO_HOME=/src/.cargo + RUSTUP_HOME=/src/.rustup win, +# i.e. the shared workspace caches. +CACHE_ENV := $(if $(CI),-e CARGO_HOME=/tmp/.cargo -e RUSTUP_HOME=/tmp/.rustup) + DOCKER_RUN = docker run --rm \ --privileged \ - -v $(CURDIR):/src \ + -v $(HOST_WS):/src \ -h lockboot \ --add-host lockboot:127.0.0.1 \ -e OWNER_UID=$(USER_ID) \ -e OWNER_GID=$(GROUP_ID) \ - -w /src + $(CACHE_ENV) \ + -w /src/$(REPO_NAME) docker-shell-base: docker-build-base - $(DOCKER_RUN) -ti $(DOCKER_SAMEUSER) $(BUILD_IMAGE) bash + $(DOCKER_RUN) -ti $(DOCKER_SAMEUSER) $(DOCKER_OPT_DOCKER) $(DOCKER_OPT_KVM) $(BUILD_IMAGE) bash + +docker-clean: + docker rmi $(BUILD_IMAGE) || true -docker-shell-dev: docker-build-dev - $(DOCKER_RUN) -ti $(DOCKER_SAMEUSER) $(DOCKER_OPT_DOCKER) $(DOCKER_OPT_KVM) $(DEV_IMAGE) bash -# Build the netboot UKI (linux.efi) for a specific architecture. stage0 serves -# this as a file and admits it by sha256 + PCR 14; it is not a bootable disk. +##################################################################### +# UKI build (stage0 serves linux.efi over the network and admits it by +# sha256 + PCR 14; it is a netboot payload, not a bootable disk). + +# Download + extract UKI dependencies in the build container, which has the tools +# (rpm2cpio, cpio, curl, xz); the host / CI runner may not (e.g. act's slim image). +# Each sub-make writes into the mounted tools/build-uki/$*/ tree. +tools/build-uki/%/busybox: docker-build-base + $(DOCKER_RUN) $(DOCKER_SAMEUSER) $(BUILD_IMAGE) make -C tools/build-uki $*/busybox + +tools/build-uki/%/stub.efi: docker-build-base + $(DOCKER_RUN) $(DOCKER_SAMEUSER) $(BUILD_IMAGE) make -C tools/build-uki $*/stub.efi + +tools/build-uki/%/kernel-core.rpm: docker-build-base + $(DOCKER_RUN) $(DOCKER_SAMEUSER) $(BUILD_IMAGE) make -C tools/build-uki $*/kernel-core.rpm + +tools/build-uki/%/kernel-modules-core.rpm: docker-build-base + $(DOCKER_RUN) $(DOCKER_SAMEUSER) $(BUILD_IMAGE) make -C tools/build-uki $*/kernel-modules-core.rpm + +# Build the netboot UKI (linux.efi) for a specific architecture. build.sh extracts +# a rootfs via the HOST docker daemon, so DOCKER_OPT_DOCKER forwards the socket. tools/build-uki/%/linux.efi: tools/build-uki/%/busybox tools/build-uki/%/stage1 tools/build-uki/%/stub.efi tools/build-uki/%/kernel-core.rpm tools/build-uki/%/kernel-modules-core.rpm tools/build-uki/mkuki $(DOCKER_RUN) $(DOCKER_OPT_DOCKER) -e ARCH=$* \ $(BUILD_IMAGE) ./tools/build-uki/build.sh # Build AND extract stage1 inside the one container step, so the cp runs where # target/ exists rather than in the host/make context, which may not see the build -# container's target dir under nested docker (e.g. `act`). `cp -v` also surfaces the -# real artifact path in the log if it ever goes missing again. --exclude mkuki: it -# is a build-host tool, built separately for x86_64 by the tools/build-uki/mkuki -# rule, so it must not be cross-compiled for $* here. +# container's target dir under nested docker (e.g. `act`). --exclude mkuki: it is a +# build-host tool, built separately for x86_64 by the tools/build-uki/mkuki rule, so +# it must not be cross-compiled for $* here. tools/build-uki/%/stage1: docker-build-base mkdir -p tools/build-uki/$* $(DOCKER_RUN) -e ARCH=$* $(DOCKER_SAMEUSER) $(BUILD_IMAGE) \ @@ -164,136 +145,137 @@ tools/build-uki/mkuki: docker-build-base ##################################################################### -# stage0 (pure-UEFI network bootloader) +# Runtime container image (busybox + stage1) -> ghcr on uki-v* tags. + +docker-buildx-setup: + docker buildx create --name lockboot-builder --use || docker buildx use lockboot-builder + docker buildx inspect --bootstrap -STAGE0_DIR = crates/stage0 +docker-runtime: tools/build-uki/x86_64/busybox tools/build-uki/x86_64/stage1 tools/build-uki/aarch64/busybox tools/build-uki/aarch64/stage1 + docker buildx build -f Dockerfile.runtime -t $(RUNTIME_IMAGE) --load . + +docker-runtime-oci: tools/build-uki/x86_64/busybox tools/build-uki/x86_64/stage1 tools/build-uki/aarch64/busybox tools/build-uki/aarch64/stage1 + docker buildx build --platform linux/amd64,linux/arm64 -f Dockerfile.runtime -t $(RUNTIME_IMAGE) --output type=oci,dest=lockboot.oci . + +.PHONY: docker-buildx-setup docker-runtime docker-runtime-oci -# Guard the arch-less forms so `make stage0` / `boot-stage0` / `test-stage0` print -# a helpful message instead of "no rule to make target". Require an explicit arch. -.PHONY: stage0 boot-stage0 test-stage0 test-chain -stage0 boot-stage0 test-stage0 test-chain: - @echo "'$@' needs an architecture suffix, e.g. 'make $@-x86_64' or 'make $@-aarch64'." >&2 - @exit 2 -# Build the stage0 UEFI binary inside the build container. Same model as stage1: -# cargo runs in the container (never the host) and vaportpm is pulled from git, -# so only this repo is mounted. -tools/build-stage0/%/stage0.efi: docker-build-base - mkdir -p tools/build-stage0/$* +##################################################################### +# example-stage2 leaf (the binary stage1 downloads, verifies, and execs). + +build/%/stage2: docker-build-base $(DOCKER_RUN) -e ARCH=$* $(DOCKER_SAMEUSER) $(BUILD_IMAGE) \ - bash -c "rustup target add $*-unknown-uefi && cargo build --release --manifest-path $(STAGE0_DIR)/Cargo.toml --target $*-unknown-uefi && cp -v $(STAGE0_DIR)/target/$*-unknown-uefi/release/stage0.efi $@" - -# Assemble + sign the stage0 boot disk (losetup/mount -> privileged container). -tools/build-stage0/%/boot.disk: tools/build-stage0/%/stage0.efi tools/build-uki/keys/db.crt - $(DOCKER_RUN) -e ARCH=$* $(BUILD_IMAGE) ./tools/build-stage0/build.sh - -stage0-amd64 stage0-x86_64: tools/build-stage0/x86_64/boot.disk -stage0-arm64 stage0-aarch64: tools/build-stage0/aarch64/boot.disk - -# Host:port the local payload server answers on. A hostname (not an IP literal) so -# the test also exercises EFI_DNS4 / the guest resolver; boot.sh maps it to -# 10.0.2.1 in the QEMU DNS. Override SERVE_HOST=10.0.2.1:8000 to skip DNS. -SERVE_HOST ?= payload.lockboot.test:8000 -PAYLOAD_URL ?= http://$(SERVE_HOST)/payload.efi - -# Shared QEMU-in-dev-container invocation for stage0 boots. The tap/iptables setup -# needs NET_ADMIN + a tun device; KVM is added when available. -STAGE0_QEMU = $(DOCKER_RUN) $(DOCKER_OPT_KVM) \ - -e YES_INSIDE_DOCKER_DO_DANGEROUS_IPTABLES=1 --cap-add=NET_ADMIN --device=/dev/net/tun \ - $(DEV_IMAGE) ./tools/qemu-test/boot.sh --kind stage0 - -# Boot stage0 under QEMU. With no arguments this builds and serves the signed -# end-to-end test payload, so `make boot-stage0-x86_64` works on its own. Knobs: -# PAYLOAD=path/to/your.efi serve a custom payload instead. Pinned by sha256, or -# by the release ed25519 key when a `.sig` and -# tools/build-stage0/keys/release.pub.b64 both exist. -# USER_DATA=path/to.json serve this `_stage1` doc verbatim (point its URL at -# anything the guest reaches); skips doc generation. -# TRACE=1 capture the guest TCP stream to stage0-trace.pcap -# (needs the dev image rebuilt: 'make docker-build-dev'). + bash -c "mkdir -p build/$* && rustup target add $*-unknown-linux-musl && cargo build --release --locked -p example-stage2 --target $*-unknown-linux-musl && cp -v target/$*-unknown-linux-musl/release/example-stage2 build/$*/stage2" + +.PHONY: stage2-amd64 stage2-x86_64 stage2-arm64 stage2-aarch64 +stage2-amd64 stage2-x86_64: build/x86_64/stage2 +stage2-arm64 stage2-aarch64: build/aarch64/stage2 + + +##################################################################### +# Full-chain test: stage0 (EXTERNAL harness) -> UKI -> stage1 -> example-stage2 # -# user-data.stage0.json (gitignored) is regenerated every run to match the payload, -# so it can never go stale. It is deliberately NOT a make-prerequisite: a missing -# one must not disqualify this rule. -boot-stage0-%: tools/qemu-test/ec2-metadata-mock-linux-amd64 tools/build-stage0/%/boot.disk tools/build-stage0/%/payload.efi - @P="$(PAYLOAD)"; [ -n "$$P" ] || P="tools/build-stage0/$*/payload.efi"; \ - if [ -n "$(USER_DATA)" ]; then \ - cp "$(USER_DATA)" user-data.stage0.json; \ - echo "Using user-data from $(USER_DATA)"; \ - elif [ -f "$$P.sig" ] && [ -f tools/build-stage0/keys/release.pub.b64 ]; then \ - PUB=$$(cat tools/build-stage0/keys/release.pub.b64); \ - printf '{\n "_stage1": {\n "%s": { "url": "%s", "ed25519": "%s" }\n }\n}\n' \ - "$*" "$(PAYLOAD_URL)" "$$PUB" > user-data.stage0.json; \ - echo "Wrote user-data.stage0.json (signed mode, release pubkey $$PUB)"; \ - else \ - SHA=$$(sha256sum "$$P" | cut -d' ' -f1); \ - printf '{\n "_stage1": {\n "%s": { "url": "%s", "sha256": "%s" }\n }\n}\n' \ - "$*" "$(PAYLOAD_URL)" "$$SHA" > user-data.stage0.json; \ - echo "Wrote user-data.stage0.json (sha256 mode, $$SHA)"; \ - fi; \ - $(STAGE0_QEMU) --arch $* --payload "$$P" $(if $(TRACE),--trace) - -# Long-term ed25519 release signing key for stage0 "signed mode". This is the -# vendor key that signs payloads; it never touches a deployed machine — stage0 -# only ever sees the *public* key, pinned in the metadata doc. Generated once in -# the build container (gitignored). release.pub.b64 is the raw 32-byte public -# key, base64-encoded, ready to drop straight into the _stage1 `ed25519` field. -tools/build-stage0/keys/release.pem: docker-build-base - mkdir -p tools/build-stage0/keys +# stage1 owns no boot apparatus. stage0 IS the harness: we borrow its boot.disk +# (built in the sibling repo) and the shared lockboot:harness image, and provide +# only {UKI, leaf, signed/pinned _stage1+_stage2 manifest} - stage0's whole +# integration surface. Local-only: needs nested KVM, so it never runs on CI. + +# Where the external stage0 boot.disk comes from. In-workspace this is the sibling +# clone; out-of-workspace, point STAGE0_BOOT_DISK at one unpacked from a stage0 release. +STAGE0_DIR ?= ../stage0 +STAGE0_BOOT_DISK ?= $(STAGE0_DIR)/build/$*/boot.disk + +# Host:port the local payload server answers on. Default to the tap gateway IP so +# BOTH hops are DNS-free: stage0 fetches the UKI (its own DNS4 is exercised by the +# stage0 repo's tests), and — crucially — stage1 fetches _stage2 from inside the +# booted Linux guest, whose DNS the shared stage0 harness does not wire for the +# mapped hostname. Override SERVE_HOST=payload.lockboot.test:8000 to also drive +# stage0's DNS4 on the _stage1 hop (the _stage2 hop then needs guest DNS). +SERVE_HOST ?= 10.0.2.1:8000 + +# ed25519 release key for SIGN=1 (signed-mode admission). stage0 only ever sees the +# public half, pinned in the _stage1 doc; the private key signs the UKI. Generated +# in the build container (gitignored under build/keys). +build/keys/release.pem: docker-build-base $(DOCKER_RUN) $(DOCKER_SAMEUSER) $(BUILD_IMAGE) bash -c "\ - openssl genpkey -algorithm ed25519 -out tools/build-stage0/keys/release.pem && \ - openssl pkey -in tools/build-stage0/keys/release.pem -pubout -outform DER \ - | tail -c 32 | base64 -w0 > tools/build-stage0/keys/release.pub.b64" - -# Build the end-to-end test payload (a chain-loaded UEFI app that reads PCRs) and -# attach a detached ed25519 signature (payload.efi.sig) made with the release -# key. The payload is NOT Secure Boot db-signed: stage0 verifies the signature -# against the pinned pubkey and loads it via a FileAuthentication override. It is -# served at $(PAYLOAD_URL) (a hostname, so the test exercises EFI_DNS4). -tools/build-stage0/%/payload.efi: docker-build-base tools/build-stage0/keys/release.pem - mkdir -p tools/build-stage0/$* - $(DOCKER_RUN) -e ARCH=$* $(DOCKER_SAMEUSER) $(BUILD_IMAGE) \ - bash -c "rustup target add $*-unknown-uefi && \ - cargo build --release --manifest-path crates/stage0-test-payload/Cargo.toml --target $*-unknown-uefi && \ - cp crates/stage0-test-payload/target/$*-unknown-uefi/release/stage0-test-payload.efi $@ && \ - openssl pkeyutl -sign -inkey tools/build-stage0/keys/release.pem -rawin -in $@ -out $@.sig" - -# The signed end-to-end test (build + sign the test payload, pin the release -# pubkey, boot stage0, fetch/verify/measure/chain-load it) is now the default for -# `boot-stage0`. This stays as a named alias for it. -test-stage0-%: - $(MAKE) boot-stage0-$* TRACE=$(TRACE) - -# Build the example stage2 binary (the leaf stage1 downloads and runs) for the -# target musl. Served locally by the full-chain test below. -tools/build-stage0/%/stage2: docker-build-base - mkdir -p tools/build-stage0/$* - $(DOCKER_RUN) -e ARCH=$* $(DOCKER_SAMEUSER) $(BUILD_IMAGE) \ - bash -c "rustup target add $*-unknown-linux-musl && cargo build --release --locked -p example-stage2 --target $*-unknown-linux-musl && cp -v target/$*-unknown-linux-musl/release/example-stage2 $@" + mkdir -p build/keys && \ + openssl genpkey -algorithm ed25519 -out build/keys/release.pem && \ + openssl pkey -in build/keys/release.pem -pubout -outform DER \ + | tail -c 32 | base64 -w0 > build/keys/release.pub.b64" + +# Detached ed25519 signature over the whole UKI (SIGN=1). Deterministic per RFC 8032, +# so `openssl pkeyutl -rawin` yields the exact bytes stage0 verifies with the pinned +# pubkey (same approach stage0 uses to sign its own test payload). Served as +# linux.efi.sig; stage0 fetches .sig when the manifest carries `ed25519`. +build/%/linux.efi.sig: tools/build-uki/%/linux.efi build/keys/release.pem + $(DOCKER_RUN) $(DOCKER_SAMEUSER) $(BUILD_IMAGE) bash -c "\ + mkdir -p build/$* && \ + openssl pkeyutl -sign -inkey build/keys/release.pem -rawin \ + -in tools/build-uki/$*/linux.efi -out build/$*/linux.efi.sig" + +# Guard the arch-less form with a helpful message instead of "no rule to make target". +.PHONY: test-chain +test-chain: + @echo "'$@' needs an arch suffix, e.g. 'make $@-x86_64' or 'make $@-aarch64'." >&2 + @exit 2 # Full-chain end-to-end test: stage0 -> UKI -> stage1 -> example-stage2, all served -# from one local directory (no S3). A single served user-data carries `_stage1` -# (stage0 admits the UKI by sha256) and `_stage2` (stage1 admits stage2 by sha256); -# the two parsers coexist on distinct keys. Both hashes are computed from the local -# files, so the doc can never go stale. -test-chain-%: tools/build-uki/%/linux.efi tools/build-stage0/%/stage2 tools/build-stage0/%/boot.disk tools/qemu-test/ec2-metadata-mock-linux-amd64 - @D="tools/build-stage0/$*/chain"; rm -rf "$$D"; mkdir -p "$$D"; \ +# from one local dir (no S3). A single served user-data carries `_stage1` (stage0 +# admits the UKI) and `_stage2` (stage1 admits the leaf by sha256); the two parsers +# coexist on distinct keys. Hashes are computed from the local files so the doc can +# never go stale. SIGN=1 additionally serves linux.efi.sig and pins the ed25519 +# pubkey for `_stage1` instead of a sha256. +test-chain-%: tools/build-uki/%/linux.efi build/%/stage2 $(if $(SIGN),build/%/linux.efi.sig) + @if [ ! -f "$(STAGE0_BOOT_DISK)" ]; then \ + echo "Missing external stage0 boot disk: $(STAGE0_BOOT_DISK)" >&2; \ + echo "Build it first: (cd $(STAGE0_DIR) && make build-$*)" >&2; \ + echo "or set STAGE0_BOOT_DISK= to one unpacked from a stage0 release." >&2; \ + exit 1; \ + fi + @D="build/$*/chain"; rm -rf "$$D"; mkdir -p "$$D"; \ cp tools/build-uki/$*/linux.efi "$$D/linux.efi"; \ - cp tools/build-stage0/$*/stage2 "$$D/stage2"; \ - UKI_SHA=$$(sha256sum "$$D/linux.efi" | cut -d' ' -f1); \ + cp build/$*/stage2 "$$D/stage2"; \ S2_SHA=$$(sha256sum "$$D/stage2" | cut -d' ' -f1); \ - printf '{\n "_stage1": { "%s": { "url": "http://%s/linux.efi", "sha256": "%s" } },\n "_stage2": { "%s": { "url": "http://%s/stage2", "sha256": "%s" } }\n}\n' \ - "$*" "$(SERVE_HOST)" "$$UKI_SHA" "$*" "$(SERVE_HOST)" "$$S2_SHA" > user-data.stage0.json; \ - echo "Wrote user-data.stage0.json (chain: UKI $$UKI_SHA, stage2 $$S2_SHA)"; \ - $(STAGE0_QEMU) --arch $* --serve-dir "$$D" $(if $(TRACE),--trace) + if [ -n "$(SIGN)" ]; then \ + cp build/$*/linux.efi.sig "$$D/linux.efi.sig"; \ + PUB=$$(cat build/keys/release.pub.b64); \ + printf '{\n "_stage1": { "%s": { "url": "http://%s/linux.efi", "ed25519": "%s" } },\n "_stage2": { "%s": { "url": "http://%s/stage2", "sha256": "%s" } }\n}\n' \ + "$*" "$(SERVE_HOST)" "$$PUB" "$*" "$(SERVE_HOST)" "$$S2_SHA" > user-data.stage0.json; \ + echo "Wrote user-data.stage0.json (signed UKI, pubkey $$PUB; stage2 sha256 $$S2_SHA)"; \ + else \ + UKI_SHA=$$(sha256sum "$$D/linux.efi" | cut -d' ' -f1); \ + printf '{\n "_stage1": { "%s": { "url": "http://%s/linux.efi", "sha256": "%s" } },\n "_stage2": { "%s": { "url": "http://%s/stage2", "sha256": "%s" } }\n}\n' \ + "$*" "$(SERVE_HOST)" "$$UKI_SHA" "$*" "$(SERVE_HOST)" "$$S2_SHA" > user-data.stage0.json; \ + echo "Wrote user-data.stage0.json (chain: UKI sha256 $$UKI_SHA, stage2 sha256 $$S2_SHA)"; \ + fi; \ + $(DOCKER_RUN) $(DOCKER_OPT_KVM) \ + -e YES_INSIDE_DOCKER_DO_DANGEROUS_IPTABLES=1 --cap-add=NET_ADMIN --device=/dev/net/tun \ + $(HARNESS_IMAGE) --kind stage0 --arch $* \ + --boot-disk "$(STAGE0_BOOT_DISK)" \ + --serve-dir "$$D" --user-data user-data.stage0.json $(if $(TRACE),--trace) ##################################################################### +# Housekeeping + +.PHONY: clean distclean +# Remove per-arch build output. Plain rm (no docker needed). build/keys/ (SIGN=1 +# release key) is left in place. +clean: + rm -rf tools/build-uki/x86_64/boot.disk tools/build-uki/x86_64/stage1 tools/build-uki/x86_64/tmp tools/build-uki/x86_64/*.img tools/build-uki/x86_64/*.efi tools/build-uki/x86_64/config-* tools/build-uki/x86_64/efi-vars.ovmf + rm -rf tools/build-uki/aarch64/boot.disk tools/build-uki/aarch64/stage1 tools/build-uki/aarch64/tmp tools/build-uki/aarch64/*.img tools/build-uki/aarch64/*.efi tools/build-uki/aarch64/config-* tools/build-uki/aarch64/efi-vars.ovmf + rm -rf build/x86_64 build/aarch64 + rm -f tools/build-uki/mkuki +distclean: clean + $(MAKE) -C tools/build-uki clean + + +##################################################################### # Git tagging helpers + TAG ?= v0.1.0 -# Create and push a new tag (or recreate if it exists) tag: @echo "Creating tag: $(TAG)" git tag -d $(TAG) 2>/dev/null || true @@ -301,16 +283,13 @@ tag: git tag -a $(TAG) -m "Release $(TAG)" git push origin $(TAG) -# Delete a tag locally and remotely untag: @echo "Deleting tag: $(TAG)" git tag -d $(TAG) 2>/dev/null || true git push origin :refs/tags/$(TAG) 2>/dev/null || true -# List all tags list-tags: git tag -l -# Amend the most recent commit with staged changes git-edit: git commit --amend --no-edit diff --git a/README.md b/README.md index ade853c..10f9e80 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,18 @@ A secure two-stage boot system using the TPM (and AWS Nitro, if available) for v ## Quick Start -The `make` based build system will create a bootable disk image to be run by Qemu (with a vTPM) to simulate a generic 'secure cloud' environment: +This repo builds the netboot **UKI** (`linux.efi`) that [stage0](https://github.com/lockboot/stage0) fetches, verifies, measures into PCR 14, and chain-loads. Build it with: ```bash -make boot-stage0-x86_64 +make x86_64 # -> tools/build-uki/x86_64/linux.efi +``` + +To exercise the whole chain under QEMU (stage0 → UKI → stage1 → example-stage2), stage0 is the harness — build its boot disk in the sibling repo, then run the chain test (borrows `../stage0/build//boot.disk` and the shared `lockboot:harness` image): + +```bash +(cd ../stage0 && make build-x86_64) # the external stage0 boot apparatus +make test-chain-x86_64 # sha256 admission (default) +make test-chain-x86_64 SIGN=1 # ed25519 signed-manifest admission ``` ## Configuration Format @@ -38,45 +46,23 @@ You can run any statically linked Linux ELF, but the minimal filesystem only has ## Components -- **[stage0](crates/stage0/README.md)**: Kernel-less UEFI netboot loader (downloads + measures + chain-loads a UEFI payload) +- **[stage0](https://github.com/lockboot/stage0)**: Kernel-less UEFI netboot loader (downloads + measures + chain-loads a UEFI payload) — its own repo; this repo's UKI is the payload it netboots - **[stage1](crates/stage1/README.md)**: Secure bootloader (fetches config, verifies binaries, extends PCRs) - **[example-stage2](crates/example-stage2/README.md)**: Example user application - **[vaportpm](https://github.com/lockboot/vaportpm)**: TPM 2.0 attestation library (external dependency) ## Cloud Deployment -Deploy the same config across AWS, GCP, or Azure. Publish scripts are provided in `tools/publish/`. +Two independent artifacts are published on two tracks: -### AWS EC2 - -Requires Nitro v4+ instances with TPM 2.0 and UEFI boot support: - -| Architecture | Tested Instance | Notes | -|---|---|---| -| x86_64 | `c6i.large` | Intel Xeon Gen 3, Nitro v4 | -| aarch64 | `c7g.medium` | Graviton 3, Nitro v4 | +- **The bootable cloud image (the stage0 Secure Boot root)** is built and published from the [stage0 repo](https://github.com/lockboot/stage0) (its `tools/publish/` bakes the AMI/GCP image from the `stage0-v*` release). That is the firmware-admitted root of trust and is not this repo's concern. +- **The netboot UKI (this repo)** is just a file served over HTTP(S): stage0 downloads it, verifies the pinned `sha256` (or `ed25519` signature), measures it into PCR 14, and chain-loads it. Publish it and print the matching `_stage1` block with: ```bash -tools/publish/ec2/create-ami.sh us-east-1 x86_64 local +tools/publish.sh s3://bucket/prefix x86_64 local # or gs://bucket/prefix ``` -### GCP Confidential VMs - -Requires Confidential VM instances with Shielded VM and custom Secure Boot keys. Uses GVE network driver (virtio-net not available on Confidential VMs). - -| Architecture | Tested Instance | Notes | -|---|---|---| -| x86_64 | `n2d-standard-2` | AMD SEV-SNP | - -```bash -tools/publish/gcp/create-image.sh my-project x86_64 local -``` - -### Azure - -```bash -az vm create --user-data "$(cat user-data.json | base64 -w0)" ... -``` +The instance's user-data carries the `_stage1` doc pointing at wherever you uploaded `linux.efi` (see [Configuration Format](#configuration-format)). ## License diff --git a/crates/example-stage2/README.md b/crates/example-stage2/README.md index 5a6563d..830eee9 100644 --- a/crates/example-stage2/README.md +++ b/crates/example-stage2/README.md @@ -193,5 +193,5 @@ at your option. ## See Also - [stage1](../stage1/) - The secure boot loader that executes this binary -- [rawdogtpm2](../rawdogtpm2/) - TPM 2.0 library used for attestation +- [vaportpm](https://github.com/lockboot/vaportpm) - TPM 2.0 library used for attestation - [Root README](../../README.md) - Full project documentation diff --git a/crates/mkuki/Cargo.toml b/crates/mkuki/Cargo.toml index 451235f..b1132b0 100644 --- a/crates/mkuki/Cargo.toml +++ b/crates/mkuki/Cargo.toml @@ -23,8 +23,9 @@ tar = "0.4" walkdir = "2" sha2 = "0.10" base64 = "0.22" -# Same ed25519 implementation stage0 verifies with (crates/stage0/src/sig.rs), -# so a signature this tool emits is exactly what stage0's admission check expects. +# Same ed25519 implementation stage0 verifies with (github.com/lockboot/stage0, +# crates/stage0/src/sig.rs), so a signature this tool emits is exactly what +# stage0's admission check expects. ed25519-compact = "2.3" # Structured, multi-level logging. The library only *emits* events; the binary # installs the subscriber (see main.rs) so importers control their own logging. diff --git a/crates/mkuki/src/sign.rs b/crates/mkuki/src/sign.rs index 139a198..5108fdd 100644 --- a/crates/mkuki/src/sign.rs +++ b/crates/mkuki/src/sign.rs @@ -1,9 +1,10 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 //! ed25519 signing + sha256, matching stage0's admission check -//! (`crates/stage0/src/sig.rs`): the signature is a detached 64-byte ed25519 -//! over the raw payload bytes, the pinned key is the base64 of the 32-byte -//! public key. +//! (github.com/lockboot/stage0, crates/stage0/src/sig.rs): the signature is a +//! detached 64-byte ed25519 over the raw payload bytes, the pinned key is the +//! base64 of the 32-byte public key. This MUST stay byte-compatible with what +//! stage0 verifies — the two are a cross-repo wire contract, not a shared crate. use anyhow::{ensure, Context, Result}; use base64::engine::general_purpose::STANDARD; diff --git a/crates/stage0-test-payload/Cargo.lock b/crates/stage0-test-payload/Cargo.lock deleted file mode 100644 index 2343d7f..0000000 --- a/crates/stage0-test-payload/Cargo.lock +++ /dev/null @@ -1,272 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "anyhow" -version = "1.0.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" - -[[package]] -name = "bit_field" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" - -[[package]] -name = "bitflags" -version = "2.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", - "subtle", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest", -] - -[[package]] -name = "libc" -version = "0.2.186" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" - -[[package]] -name = "log" -version = "0.4.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "ptr_meta" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b9a0cf95a1196af61d4f1cbdab967179516d9a4a4312af1f31948f8f6224a79" -dependencies = [ - "ptr_meta_derive", -] - -[[package]] -name = "ptr_meta_derive" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7347867d0a7e1208d93b46767be83e2b8f978c3dad35f775ac8d8847551d6fe1" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "stage0-test-payload" -version = "0.1.0" -dependencies = [ - "anyhow", - "hex", - "uefi", - "vaportpm-attest", -] - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "syn" -version = "2.0.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - -[[package]] -name = "ucs2" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79298e11f316400c57ec268f3c2c29ac3c4d4777687955cd3d4f3a35ce7eba" -dependencies = [ - "bit_field", -] - -[[package]] -name = "uefi" -version = "0.35.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7569ceafb898907ff764629bac90ac24ba4203c38c33ef79ee88c74aa35b11" -dependencies = [ - "bitflags", - "cfg-if", - "log", - "ptr_meta", - "ucs2", - "uefi-macros", - "uefi-raw", - "uguid", -] - -[[package]] -name = "uefi-macros" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3dad47b3af8f99116c0f6d4d669c439487d9aaf1c8d9480d686cda6f3a8aa23" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "uefi-raw" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cad96b8baaf1615d3fdd0f03d04a0b487d857c1b51b19dcbfe05e2e3c447b78" -dependencies = [ - "bitflags", - "uguid", -] - -[[package]] -name = "uguid" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab14ea9660d240e7865ce9d54ecdbd1cd9fa5802ae6f4512f093c7907e921533" - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "vaportpm-attest" -version = "0.1.0" -source = "git+https://github.com/lockboot/vaportpm#15770b11a477de3cb92207a76ae27a43ece695de" -dependencies = [ - "anyhow", - "hex", - "hmac", - "sha1", - "sha2", -] - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" diff --git a/crates/stage0-test-payload/Cargo.toml b/crates/stage0-test-payload/Cargo.toml deleted file mode 100644 index 764aa7f..0000000 --- a/crates/stage0-test-payload/Cargo.toml +++ /dev/null @@ -1,24 +0,0 @@ -[package] -name = "stage0-test-payload" -version = "0.1.0" -edition = "2021" -license = "MIT OR Apache-2.0" - -# Standalone workspace (UEFI-only), like stage0. -[workspace] - -[[bin]] -name = "stage0-test-payload" -path = "src/main.rs" - -[dependencies] -uefi = { version = "0.35", features = ["alloc", "global_allocator", "panic_handler"] } -vaportpm-attest = { git = "https://github.com/lockboot/vaportpm", default-features = false } -anyhow = { version = "1.0", default-features = false } -hex = { version = "0.4", default-features = false, features = ["alloc"] } - -[profile.release] -opt-level = "s" -lto = true -codegen-units = 1 -panic = "abort" diff --git a/crates/stage0-test-payload/src/main.rs b/crates/stage0-test-payload/src/main.rs deleted file mode 100644 index a7e274e..0000000 --- a/crates/stage0-test-payload/src/main.rs +++ /dev/null @@ -1,84 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -//! A trivial UEFI payload for exercising the stage0 netboot path end to end. -//! -//! When chain-loaded by stage0 it prints a banner and reads back PCR 14 (the -//! payload measurement) over `EFI_TCG2_PROTOCOL`, proving the -//! measure-then-execute flow worked. PCR 15 is read too and should be all-zero, -//! confirming stage0 measures only the binary. A real payload would instead set -//! up and boot its own OS. - -#![no_std] -#![no_main] - -extern crate alloc; - -use alloc::boxed::Box; -use alloc::vec; -use alloc::vec::Vec; -use anyhow::{anyhow, bail, Result}; -use uefi::boot::{self, ScopedProtocol}; -use uefi::prelude::*; -use uefi::println; -use uefi::proto::tcg::v2::Tcg; -use vaportpm_attest::{PcrOps, TpmAlg, TpmTransport}; - -struct Tcg2Transport { - tcg: ScopedProtocol, - max_response_size: usize, -} - -impl TpmTransport for Tcg2Transport { - fn transmit_raw(&mut self, command: &[u8]) -> Result> { - let mut out = vec![0u8; self.max_response_size.max(64)]; - self.tcg - .submit_command(command, &mut out) - .map_err(|e| anyhow!("SubmitCommand failed: {:?}", e.status()))?; - if out.len() < 10 { - bail!("short TPM response"); - } - let size = u32::from_be_bytes([out[2], out[3], out[4], out[5]]) as usize; - if size < 10 || size > out.len() { - bail!("bad TPM response size {}", size); - } - out.truncate(size); - Ok(out) - } -} - -#[entry] -fn main() -> Status { - uefi::helpers::init().unwrap(); - println!("payload: hello from the chain-loaded UEFI payload"); - - match print_pcrs() { - Ok(()) => {} - Err(e) => println!("payload: could not read PCRs: {e}"), - } - - // A real payload would ExitBootServices and boot an OS here. - println!("payload: done"); - boot::stall(5_000_000); - Status::SUCCESS -} - -fn print_pcrs() -> Result<()> { - let handle = boot::get_handle_for_protocol::() - .map_err(|e| anyhow!("no EFI_TCG2_PROTOCOL: {:?}", e.status()))?; - let mut tcg = boot::open_protocol_exclusive::(handle) - .map_err(|e| anyhow!("open EFI_TCG2_PROTOCOL: {:?}", e.status()))?; - let max_response_size = tcg - .get_capability() - .map_err(|e| anyhow!("get_capability: {:?}", e.status()))? - .max_response_size as usize; - - let mut tpm = vaportpm_attest::Tpm::with_transport(Box::new(Tcg2Transport { - tcg, - max_response_size, - })); - - for (idx, value) in tpm.pcr_read_bank(&[14, 15], TpmAlg::Sha256)? { - println!("payload: PCR{idx} (sha256) = {}", hex::encode(value)); - } - Ok(()) -} diff --git a/crates/stage0/Cargo.lock b/crates/stage0/Cargo.lock deleted file mode 100644 index 80bddfe..0000000 --- a/crates/stage0/Cargo.lock +++ /dev/null @@ -1,351 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "anyhow" -version = "1.0.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "bit_field" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" - -[[package]] -name = "bitflags" -version = "2.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", - "subtle", -] - -[[package]] -name = "ed25519-compact" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5c0284a5d4b1a2fae017a9fe55fd7d01699711f1b572493f16593e173ea2801" - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "libc" -version = "0.2.186" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" - -[[package]] -name = "log" -version = "0.4.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" - -[[package]] -name = "memchr" -version = "2.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "ptr_meta" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b9a0cf95a1196af61d4f1cbdab967179516d9a4a4312af1f31948f8f6224a79" -dependencies = [ - "ptr_meta_derive", -] - -[[package]] -name = "ptr_meta_derive" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7347867d0a7e1208d93b46767be83e2b8f978c3dad35f775ac8d8847551d6fe1" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.150" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "stage0" -version = "0.1.0" -dependencies = [ - "anyhow", - "base64", - "ed25519-compact", - "hex", - "serde", - "serde_json", - "sha2", - "uefi", - "uefi-raw", - "vaportpm-attest", -] - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "syn" -version = "2.0.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - -[[package]] -name = "ucs2" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79298e11f316400c57ec268f3c2c29ac3c4d4777687955cd3d4f3a35ce7eba" -dependencies = [ - "bit_field", -] - -[[package]] -name = "uefi" -version = "0.35.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7569ceafb898907ff764629bac90ac24ba4203c38c33ef79ee88c74aa35b11" -dependencies = [ - "bitflags", - "cfg-if", - "log", - "ptr_meta", - "ucs2", - "uefi-macros", - "uefi-raw", - "uguid", -] - -[[package]] -name = "uefi-macros" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3dad47b3af8f99116c0f6d4d669c439487d9aaf1c8d9480d686cda6f3a8aa23" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "uefi-raw" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cad96b8baaf1615d3fdd0f03d04a0b487d857c1b51b19dcbfe05e2e3c447b78" -dependencies = [ - "bitflags", - "uguid", -] - -[[package]] -name = "uguid" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab14ea9660d240e7865ce9d54ecdbd1cd9fa5802ae6f4512f093c7907e921533" - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "vaportpm-attest" -version = "0.1.0" -source = "git+https://github.com/lockboot/vaportpm#15770b11a477de3cb92207a76ae27a43ece695de" -dependencies = [ - "anyhow", - "hex", - "hmac", - "sha1", - "sha2", -] - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/crates/stage0/Cargo.toml b/crates/stage0/Cargo.toml deleted file mode 100644 index 98e97f2..0000000 --- a/crates/stage0/Cargo.toml +++ /dev/null @@ -1,45 +0,0 @@ -[package] -name = "stage0" -version = "0.1.0" -edition = "2021" -license = "MIT OR Apache-2.0" - -# Standalone workspace: stage0 only builds for *-unknown-uefi, so it is kept out -# of the parent lockboot workspace (which defaults to a musl target). -[workspace] - -[features] -# Verbose per-connection/per-request/per-segment network trace via `sdbg!`. Off by -# default (the milestone log + boot-clock timestamps suffice); build with -# `--features verbose` to debug DNS/TCP/HTTP behaviour. -verbose = [] - -[dependencies] -# UEFI runtime: entry point, boot services, protocols (HTTP, TCG2, LoadImage). -uefi = { version = "0.35", features = ["alloc", "global_allocator", "panic_handler"] } -uefi-raw = "0.11" - -# no_std TPM core — provides TpmTransport + PCR ops over our EFI_TCG2 transport. -# Consumed from git like stage1 (the build never sees a local vaportpm checkout). -vaportpm-attest = { git = "https://github.com/lockboot/vaportpm", default-features = false } - -# no_std JSON + hashing for the metadata doc and payload integrity check. -serde = { version = "1.0", default-features = false, features = ["derive", "alloc"] } -serde_json = { version = "1.0", default-features = false, features = ["alloc"] } -sha2 = { version = "0.10", default-features = false, features = ["force-soft"] } -hex = { version = "0.4", default-features = false, features = ["alloc"] } -base64 = { version = "0.22", default-features = false, features = ["alloc"] } - -# ed25519 signature verification for "signed mode" payloads. no_std, verify-only -# (no `random`/`std`): the release public key is pinned in the metadata doc and -# the detached .sig is fetched alongside the payload. -ed25519-compact = { version = "2", default-features = false } - -# Error type at the TpmTransport boundary (vaportpm-attest uses anyhow::Result). -anyhow = { version = "1.0", default-features = false } - -[profile.release] -opt-level = "s" -lto = true -codegen-units = 1 -panic = "abort" diff --git a/crates/stage0/README.md b/crates/stage0/README.md deleted file mode 100644 index 10a951a..0000000 --- a/crates/stage0/README.md +++ /dev/null @@ -1,107 +0,0 @@ -# stage0 - measured UEFI network bootloader - -A kernel-less UEFI application the firmware boots directly. It fetches a -`_stage1` document from the cloud metadata service, downloads the UEFI payload it -names, admits it (pinned hash or signature), measures it into the TPM, and -chain-loads it - native UEFI sibling of `stage1`. - -## Using it - -stage0 ships as a `db`-signed boot disk; use it as your VM's boot volume. Point -it at your payload with a `_stage1` user-data document: - -```json -{ - "_stage1": { - "x86_64": { - "url": "http://cdn.example.com/app.efi", - "sha256": "<64-hex sha256>" - }, - "aarch64": { - "url": "http://cdn.example.com/app.efi", - "ed25519": "", - "args_url": "http://cdn.example.com/app.args", // optional - } - } -} -``` - -Per arch, pick the admission mode: - -- **`sha256`**: pin an exact hash. Immutable; re-pin for every build. -- **`ed25519`**: pin a long-term release public key. The payload rolls forward - without editing metadata: sign each build offline and serve the detached - signature at `.sig`, or at a `sig_url` of your choice. A `{sha256}` in - `sig_url` is replaced with the payload's hash, so signatures can be - content-addressed (e.g. `http://cdn.example.com/sigs/{sha256}.sig`). - -The payload must be a UEFI PE. However the firmware `db` feels about it, stage0 -admits it by your pin/signature and measures it into **PCR 14** (= its SHA-256). - -### Embedded metadata (self-contained `netboot.efi`) - -The `_stage1` document can be embedded in stage0's PE before Authenticode -signing. If a `.stage0` section is present, stage0 reads the document from that -section and does not contact the metadata service. The metadata is either embedded -or fetched, never both. - -The section holds the complete user-data JSON: the same `{ "_stage1": { ... } }` -document the metadata service would return, not just the inner object. It is part -of the signed, firmware-measured image, so the key, URL and args it pins are fixed -at signing time. The result is a single file that runs one fixed configuration, -with the payload still gated by your release key. - -Embed the document, then sign: - - objcopy --add-section .stage0=user-data.json \ - --set-section-flags .stage0=alloc,load,readonly,data \ - stage0.efi netboot.efi - sbsign --key db.key --cert db.crt --output netboot.efi netboot.efi - -The section must be loaded: mapped at its virtual address, with `SizeOfImage` -covering it. If it is not, stage0 ignores it and falls back to the metadata -service. - -## What it does - -On boot, in order: - -1. Brings the NIC up via DHCP (`EFI_IP4_CONFIG2`). -2. Fetches `_stage1` user-data from the metadata service, trying - EC2 IMDSv2, GCP, Azure & Aliyun at their fixed IPs. -3. Downloads the per-arch payload from `url` (hostnames resolved via `EFI_DNS4`). - All networking is raw `EFI_TCP4`, no `EFI_HTTP` or TLS; integrity comes from - the pin/signature, not the transport. -4. **Admits** it: its SHA-256 must equal the pinned `sha256`, or a detached - ed25519 signature (`.sig`) must verify against the pinned `ed25519` key. -5. **Measures** it: `PCR 14 ← SHA-256(payload)` via `EFI_TCG2_PROTOCOL`. Nothing - else is measured; attestation is simply "stage0 ran and loaded this hash" - (no config, key, or PCR 15). -6. **Chain-loads** it (`LoadImage` from memory + `StartImage`), bypassing the - firmware `db` check with a temporary `FileAuthentication` override so - late-bound payloads need no `db` signature. - -stage0 is itself `db`-signed and measured, so the chain stays attestable; the -pin/signature is admission control only and is never attested. - -## `_stage1` metadata reference - -A `_stage1` object with an optional `args` and one entry per architecture. Each -arch entry needs `url` **and exactly one** of `sha256` or `ed25519`. - -| Field | In | Type | Rules | -|---|---|---|---| -| `args` | `_stage1` | `string[]` | optional; passed to the payload as UEFI load options | -| `x86_64` / `aarch64` | `_stage1` | object | per-arch entry; the running arch's must be present | -| `url` | arch entry | `string` | `http://…`, printable ASCII (TLS is not used) | -| `sha256` | arch entry | `string` | exactly 64 hex characters | -| `ed25519` | arch entry | `string` | base64 of a 32-byte public key | -| `sig_url` | arch entry | `string` | optional (signed mode); payload signature location, `{sha256}` → payload hash. Defaults to `.sig` | -| `args_url` | arch entry | `string` | optional (signed mode only); fetch signed load options here, `{sha256}` → payload hash. Overrides inline `args` | -| `args_sig_url` | arch entry | `string` | optional; signature for `args_url`, `{sha256}` → payload hash. Defaults to `.sig`. Requires `args_url` | - -`args_url` content is verified against `ed25519` (the same release key as the -payload) and used verbatim, trimmed, as the load-options string. - -The document is shared with `stage1`'s `_stage2`; the distinct `_stage1` key -keeps a UEFI payload from being confused with a Linux one. diff --git a/crates/stage0/src/config.rs b/crates/stage0/src/config.rs deleted file mode 100644 index 1b73d83..0000000 --- a/crates/stage0/src/config.rs +++ /dev/null @@ -1,160 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -//! The `_stage1` metadata schema. -//! -//! Mirrors `stage1`'s per-arch `{url, sha256}` structure (plus optional `args`) -//! but under a distinct `_stage1` key, so a UEFI payload is never confused with -//! a Linux `_stage2` binary in the same document. - -use alloc::string::String; -use alloc::vec::Vec; -use base64::engine::general_purpose::STANDARD; -use base64::Engine as _; -use serde::Deserialize; - -#[derive(Debug, Deserialize)] -pub struct UserData { - #[serde(rename = "_stage1")] - pub stage1: Stage1Config, -} - -#[derive(Debug, Deserialize)] -pub struct Stage1Config { - #[serde(default)] - pub args: Option>, - // Exactly one of these is read per build (see `for_this_arch`); the other - // is still deserialized so a single multi-arch document works everywhere. - #[cfg_attr(not(target_arch = "aarch64"), allow(dead_code))] - #[serde(default)] - pub aarch64: Option, - #[cfg_attr(not(target_arch = "x86_64"), allow(dead_code))] - #[serde(default)] - pub x86_64: Option, -} - -#[derive(Debug, Deserialize)] -pub struct ArchConfig { - pub url: String, - // Exactly one of these selects the verification mode (see `verify`): - // sha256 → pin an exact hash (immutable payload). - // ed25519 → pin a long-term release pubkey (base64); the payload may roll - // forward without editing metadata, gated by a detached `.sig`. - #[serde(default)] - pub sha256: Option, - #[serde(default)] - pub ed25519: Option, - /// Where the detached ed25519 signature lives (signed mode). Any `{sha256}` - /// is replaced with the payload's hex digest, so the signature can be - /// content-addressed. Defaults to `.sig` when omitted. - #[serde(default)] - pub sig_url: Option, - /// Optional signed load options (ed25519 mode only). The args are fetched from - /// `args_url` (with `{sha256}` substituted), and their detached signature from - /// `args_sig_url` (with `{sha256}` substituted), or `.sig` when that - /// is omitted. The signature is verified against the same release key as the - /// payload; the verified bytes are used verbatim as the payload's UEFI load - /// options, overriding inline `args`. - #[serde(default)] - pub args_url: Option, - #[serde(default)] - pub args_sig_url: Option, -} - -/// How stage0 admits the downloaded payload before measuring + loading it. -pub enum Verify { - /// Payload's SHA-256 must equal this 64-hex string. - Sha256(String), - /// Detached ed25519 signature must verify against this base64-encoded 32-byte - /// release public key. `sig_url` is where the payload signature is fetched from - /// (or `None` to default to `.sig`). `args_url`/`args_sig_url` optionally - /// add signed load options verified against the same key. All `*_url` values - /// still carry an unsubstituted `{sha256}`; the caller substitutes it. - Ed25519 { - pubkey: String, - sig_url: Option, - args_url: Option, - args_sig_url: Option, - }, -} - -impl Stage1Config { - /// The config entry for the architecture stage0 was built for. - #[must_use] - pub fn for_this_arch(&self) -> Option<&ArchConfig> { - #[cfg(target_arch = "x86_64")] - { - self.x86_64.as_ref() - } - #[cfg(target_arch = "aarch64")] - { - self.aarch64.as_ref() - } - #[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))] - { - None - } - } -} - -impl ArchConfig { - /// Validate the URL and the (single) verification field, returning the - /// selected [`Verify`] mode. - pub fn validate(&self) -> Result { - // http:// only: stage0's TCP4 client speaks plain HTTP, TLS is not used - // (integrity comes from the pin/signature, not the transport). Rejecting - // https:// here turns an unfetchable URL into a clear config-time error - // rather than a late download failure. - if !self.url.starts_with("http://") { - return Err("url must start with http:// (TLS is not supported)"); - } - if !self.url.chars().all(|c| c.is_ascii_graphic()) { - return Err("url must contain only printable ASCII"); - } - // Same transport rule as `url` for the optional signature/args URLs. - let ok_url = |s: &str| s.starts_with("http://") && s.chars().all(|c| c.is_ascii_graphic()); - if self.sig_url.as_deref().is_some_and(|s| !ok_url(s)) { - return Err("sig_url must start with http:// and be printable ASCII"); - } - if self.args_url.as_deref().is_some_and(|s| !ok_url(s)) { - return Err("args_url must start with http:// and be printable ASCII"); - } - if self.args_sig_url.as_deref().is_some_and(|s| !ok_url(s)) { - return Err("args_sig_url must start with http:// and be printable ASCII"); - } - if self.args_sig_url.is_some() && self.args_url.is_none() { - return Err("args_sig_url requires args_url"); - } - match (&self.sha256, &self.ed25519) { - (Some(_), Some(_)) => Err("specify only one of sha256 / ed25519"), - (None, None) => Err("must specify one of sha256 / ed25519"), - (Some(hex), None) => { - // Signed args need the release key, which only signed mode pins. - if self.args_url.is_some() { - return Err("args_url requires ed25519 signed mode"); - } - if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) { - return Err("sha256 must be exactly 64 hex characters"); - } - Ok(Verify::Sha256(hex.clone())) - } - (None, Some(pubkey)) => { - // A raw ed25519 public key is 32 bytes. - match STANDARD.decode(pubkey.trim()) { - Ok(bytes) if bytes.len() == 32 => Ok(Verify::Ed25519 { - pubkey: pubkey.clone(), - sig_url: self.sig_url.clone(), - args_url: self.args_url.clone(), - args_sig_url: self.args_sig_url.clone(), - }), - Ok(_) => Err("ed25519 pubkey must decode to 32 bytes"), - Err(_) => Err("ed25519 pubkey must be base64"), - } - } - } - } -} - -/// Parse the user-data JSON into a [`UserData`]. -pub fn parse(json: &[u8]) -> Result { - serde_json::from_slice(json).map_err(|_| "invalid JSON or missing _stage1 key") -} diff --git a/crates/stage0/src/dns4.rs b/crates/stage0/src/dns4.rs deleted file mode 100644 index 30ff0dd..0000000 --- a/crates/stage0/src/dns4.rs +++ /dev/null @@ -1,292 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -//! Hostname resolution over `EFI_DNS4_PROTOCOL`. -//! -//! Metadata is reached at fixed link-local IPs, but a payload URL may name a host -//! (e.g. an S3/GCS bucket). The HTTP client (`http.rs`) calls [`resolve`] for any -//! non-literal host, turning it into an IPv4 address before the TCP connect. -//! -//! The DNS instance is configured statically from the IPv4 lease `http.rs` already -//! established (station address + DHCP-provided DNS server list, read from -//! `EFI_IP4_CONFIG2`). Do NOT switch to `UseDefaultSetting = TRUE`: it makes the -//! DNS driver bring its own IP4/UDP4 child up via a second DHCP, multiple seconds -//! for a query that resolves in milliseconds. `uefi-raw` 0.11 does not expose DNS4, -//! so the FFI bindings (UEFI spec, EFI_DNS4_PROTOCOL) are defined here. - -use alloc::vec::Vec; -use core::ffi::c_void; -use core::ptr; - -use uefi::boot::{self, OpenProtocolAttributes, OpenProtocolParams}; -use uefi::proto::network::ip4config2::Ip4Config2; -use uefi::proto::unsafe_protocol; -use uefi::{CString16, Status}; -use uefi_raw::protocol::driver::ServiceBindingProtocol; -use uefi_raw::protocol::network::ip4_config2::Ip4Config2DataType; -use uefi_raw::{Boolean, Event, Ipv4Address}; - -// ---- EFI_DNS4_PROTOCOL FFI (UEFI spec) ---- - -#[repr(C)] -struct Dns4ConfigData { - dns_server_list_count: usize, - dns_server_list: *mut Ipv4Address, - use_default_setting: Boolean, - enable_dns_cache: Boolean, - protocol: u8, - station_ip: Ipv4Address, - subnet_mask: Ipv4Address, - local_port: u16, - retry_count: u32, - retry_interval: u32, -} - -#[repr(C)] -struct Dns4CompletionToken { - event: Event, - status: Status, - retry_count: u32, - retry_interval: u32, - // Union of response pointers; for HostNameToIp it is the H2AData pointer. - rsp_data: *mut Dns4HostToAddrData, -} - -#[repr(C)] -struct Dns4HostToAddrData { - ip_count: u32, - ip_list: *mut Ipv4Address, -} - -/// `EFI_DNS4_PROTOCOL` method table. Unused slots keep the spec order/size but -/// a placeholder signature (never invoked). -#[repr(C)] -struct Dns4Protocol { - get_mode_data: unsafe extern "efiapi" fn() -> Status, - configure: unsafe extern "efiapi" fn(*mut Dns4Protocol, *const Dns4ConfigData) -> Status, - host_name_to_ip: unsafe extern "efiapi" fn( - *mut Dns4Protocol, - *const u16, - *mut Dns4CompletionToken, - ) -> Status, - ip_to_host_name: unsafe extern "efiapi" fn() -> Status, - general_lookup: unsafe extern "efiapi" fn() -> Status, - update_dns_cache: unsafe extern "efiapi" fn() -> Status, - poll: unsafe extern "efiapi" fn(*mut Dns4Protocol) -> Status, - cancel: unsafe extern "efiapi" fn() -> Status, -} - -#[unsafe_protocol("b625b186-e063-44f7-8905-6a74dc6f52b4")] -struct Dns4Sb(ServiceBindingProtocol); - -#[unsafe_protocol("ae3d28cc-e05b-4fa1-a011-7eb55a3f1401")] -struct Dns4(Dns4Protocol); - -/// EFI_IP_PROTO_UDP: DNS queries ride UDP. -const IP_PROTO_UDP: u8 = 17; - -/// Spin on the token's volatile `status`, pumping the driver via `Poll()` with no -/// inter-poll stall (see the matching note in `tcp4::pump`). Bounded by a real -/// wall-clock `budget_ms` via the boot clock. -unsafe fn pump(dns: *mut Dns4Protocol, status: *const Status, budget_ms: u64) -> Status { - let start = crate::timing::since_boot_ms(); - loop { - let s = ptr::read_volatile(status); - if s != Status::NOT_READY { - return s; - } - let _ = ((*dns).poll)(dns); - if crate::timing::since_boot_ms().wrapping_sub(start) >= budget_ms { - return Status::TIMEOUT; - } - } -} - -fn new_event() -> Result { - unsafe { - boot::create_event( - uefi::boot::EventType::empty(), - uefi::boot::Tpl::CALLBACK, - None, - None, - ) - } - .map(|e| e.as_ptr()) - .map_err(|e| e.status()) -} - -/// The DHCP lease `http.rs` already established: station address plus DNS server -/// list. Reused so the DNS instance can configure statically (see module docs). -struct Ip4Lease { - dns_servers: Vec, - station_ip: Ipv4Address, - subnet_mask: Ipv4Address, -} - -/// Read the existing IPv4 lease (address + DHCP-provided DNS servers) from -/// `EFI_IP4_CONFIG2` on the NIC. `None` if anything is missing; the caller then -/// falls back to letting the DNS driver bring up its own setting. -fn ip4_lease() -> Option { - let handle = boot::get_handle_for_protocol::().ok()?; - let mut ip4 = Ip4Config2::new(handle).ok()?; - let info = ip4.get_interface_info().ok()?; - // DNS_SERVER data is a packed array of EFI_IPv4_ADDRESS (4 bytes each). - let dns_servers: Vec = ip4 - .get_data(Ip4Config2DataType::DNS_SERVER) - .ok()? - .chunks_exact(4) - .map(|c| Ipv4Address([c[0], c[1], c[2], c[3]])) - .collect(); - if dns_servers.is_empty() || info.station_addr.0 == [0, 0, 0, 0] { - return None; - } - Some(Ip4Lease { - dns_servers, - station_ip: info.station_addr, - subnet_mask: info.subnet_mask, - }) -} - -/// Resolve `host` to an IPv4 address using the DHCP-provided DNS servers. -pub fn resolve(host: &str) -> Result<[u8; 4], Status> { - crate::sdbg!("stage0: EFI_DNS4 resolving {host}"); - let sb_handle = boot::get_handle_for_protocol::().map_err(|e| { - crate::slog!("stage0: no EFI_DNS4 service binding: {:?}", e.status()); - e.status() - })?; - let mut sb = unsafe { - boot::open_protocol::( - OpenProtocolParams { - handle: sb_handle, - agent: boot::image_handle(), - controller: None, - }, - OpenProtocolAttributes::GetProtocol, - ) - .map_err(|e| e.status())? - }; - - let mut child: uefi_raw::Handle = ptr::null_mut(); - let st = unsafe { (sb.0.create_child)(&mut sb.0, &mut child) }; - if st != Status::SUCCESS { - crate::slog!("stage0: EFI_DNS4 create_child failed: {st:?}"); - return Err(st); - } - let child_handle = unsafe { uefi::Handle::from_ptr(child).ok_or(Status::DEVICE_ERROR)? }; - - let result = resolve_on_child(child_handle, host); - - let _ = unsafe { (sb.0.destroy_child)(&mut sb.0, child) }; - result -} - -fn resolve_on_child(child: uefi::Handle, host: &str) -> Result<[u8; 4], Status> { - let mut dns = unsafe { - boot::open_protocol::( - OpenProtocolParams { - handle: child, - agent: boot::image_handle(), - controller: None, - }, - OpenProtocolAttributes::GetProtocol, - ) - .map_err(|e| e.status())? - }; - let dns_ptr: *mut Dns4Protocol = &mut dns.0; - - // Static config from the existing lease (see module docs). `lease` must outlive - // `configure`, since `cfg` borrows its DNS-server Vec by raw pointer. - let lease = ip4_lease(); - let cfg = match &lease { - Some(l) => Dns4ConfigData { - dns_server_list_count: l.dns_servers.len(), - dns_server_list: l.dns_servers.as_ptr() as *mut Ipv4Address, - use_default_setting: Boolean::from(false), - enable_dns_cache: Boolean::from(false), - protocol: IP_PROTO_UDP, - station_ip: l.station_ip, - subnet_mask: l.subnet_mask, - local_port: 0, - retry_count: 2, - retry_interval: 0, - }, - // No readable lease: fall back to UseDefaultSetting (driver does its own DHCP). - None => Dns4ConfigData { - dns_server_list_count: 0, - dns_server_list: ptr::null_mut(), - use_default_setting: Boolean::from(true), - enable_dns_cache: Boolean::from(false), - protocol: IP_PROTO_UDP, - station_ip: Ipv4Address([0, 0, 0, 0]), - subnet_mask: Ipv4Address([0, 0, 0, 0]), - local_port: 0, - retry_count: 2, - retry_interval: 0, - }, - }; - let st = unsafe { ((*dns_ptr).configure)(dns_ptr, &cfg) }; - if st != Status::SUCCESS { - crate::slog!("stage0: EFI_DNS4 configure failed: {st:?} (no DHCP-provided DNS server?)"); - return Err(st); - } - crate::sdbg!( - "stage0: EFI_DNS4 configured ({}), sending query", - if lease.is_some() { "static, reusing lease" } else { "UseDefaultSetting" } - ); - - let name = CString16::try_from(host).map_err(|_| Status::INVALID_PARAMETER)?; - let event = new_event()?; - let mut token = Dns4CompletionToken { - event, - status: Status::NOT_READY, - retry_count: 0, - retry_interval: 0, - rsp_data: ptr::null_mut(), - }; - let call = unsafe { ((*dns_ptr).host_name_to_ip)(dns_ptr, name.as_ptr().cast(), &mut token) }; - let st = if call == Status::SUCCESS { - unsafe { pump(dns_ptr, &token.status, 10_000) } - } else { - call - }; - let _ = unsafe { uefi::Event::from_ptr(event).map(boot::close_event) }; - - // Reset the instance regardless of outcome. - let _ = unsafe { ((*dns_ptr).configure)(dns_ptr, ptr::null()) }; - - if st != Status::SUCCESS { - crate::slog!("stage0: EFI_DNS4 HostNameToIp({host}) failed: {st:?}"); - return Err(st); - } - - let h2a = token.rsp_data; - if h2a.is_null() { - crate::slog!("stage0: EFI_DNS4 returned no response data for {host}"); - return Err(Status::DEVICE_ERROR); - } - let ip = unsafe { - let data = &*h2a; - if data.ip_count == 0 || data.ip_list.is_null() { - free_h2a(h2a); - crate::slog!("stage0: EFI_DNS4 found no addresses for {host}"); - return Err(Status::NOT_FOUND); - } - (*data.ip_list).0 - }; - unsafe { free_h2a(h2a) }; - crate::sdbg!( - "stage0: resolved {host} -> {}.{}.{}.{}", - ip[0], ip[1], ip[2], ip[3] - ); - Ok(ip) -} - -/// Free the driver-allocated response data (the IP list and the struct itself). -unsafe fn free_h2a(h2a: *mut Dns4HostToAddrData) { - let data = &*h2a; - if let Some(p) = ptr::NonNull::new(data.ip_list.cast::()) { - let _ = boot::free_pool(p.cast()); - } - if let Some(p) = ptr::NonNull::new(h2a.cast::()) { - let _ = boot::free_pool(p.cast()); - } -} diff --git a/crates/stage0/src/embedded.rs b/crates/stage0/src/embedded.rs deleted file mode 100644 index 87a49c4..0000000 --- a/crates/stage0/src/embedded.rs +++ /dev/null @@ -1,86 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -//! Optional `_stage1` metadata embedded in stage0's own PE image. -//! -//! A deployer can bake a `_stage1` document into stage0 as a PE section named -//! [`SECTION`], then `db`-sign the result into a single `netboot.efi`. The section -//! is part of the signed, firmware-measured PE, so the key, URL and args it -//! carries are fixed at signing time, and no metadata service is contacted. -//! When present it is used in place of the cloud metadata fetch. -//! -//! The section must be loaded into the image (mapped at its virtual address with -//! `SizeOfImage` covering it). If embedding leaves it unmapped, [`metadata`] -//! simply returns `None` and stage0 falls back to the metadata service. - -use alloc::vec::Vec; - -use uefi::boot; -use uefi::proto::loaded_image::LoadedImage; - -/// PE section name carrying the embedded `_stage1` JSON (8 bytes, NUL-padded). -const SECTION: &[u8; 8] = b".stage0\0"; - -/// The embedded `_stage1` document, or `None` if stage0's PE carries no -/// [`SECTION`]. Every read is bounds-checked against the loaded image size; any -/// malformation yields `None` (the caller then falls back to the metadata fetch). -pub fn metadata() -> Option> { - let loaded = boot::open_protocol_exclusive::(boot::image_handle()).ok()?; - let (base, size) = loaded.info(); - if base.is_null() || size == 0 { - return None; - } - // SAFETY: `base..base+size` is stage0's own loaded image (mapped, initialized, - // readable). The slice is read-only and every access below goes through - // bounds-checked `get`, so nothing dereferences out of range. - let img = unsafe { core::slice::from_raw_parts(base as *const u8, size as usize) }; - - let (off, len) = find_section(img, SECTION)?; - let raw = img.get(off..off.checked_add(len)?)?; - // Drop section zero/whitespace padding so the JSON parses cleanly. - let end = raw - .iter() - .rposition(|&b| b != 0 && !b.is_ascii_whitespace()) - .map_or(0, |i| i + 1); - (end != 0).then(|| raw[..end].to_vec()) -} - -/// Locate a named section in a loaded PE image, returning `(offset-from-base, -/// virtual-size)` of its in-memory data. Fully bounds-checked; `None` on any -/// malformation or if the section's mapped range exceeds the image. -fn find_section(img: &[u8], name: &[u8; 8]) -> Option<(usize, usize)> { - let rd_u16 = |o: usize| Some(u16::from_le_bytes([*img.get(o)?, *img.get(o + 1)?])); - let rd_u32 = |o: usize| { - Some(u32::from_le_bytes([ - *img.get(o)?, - *img.get(o + 1)?, - *img.get(o + 2)?, - *img.get(o + 3)?, - ])) - }; - - if img.get(0..2)? != b"MZ" { - return None; - } - let pe = rd_u32(0x3c)? as usize; - if img.get(pe..pe.checked_add(4)?)? != b"PE\0\0" { - return None; - } - let coff = pe + 4; - let num_sections = rd_u16(coff + 2)? as usize; - let opt_size = rd_u16(coff + 16)? as usize; - let mut sh = coff.checked_add(20)?.checked_add(opt_size)?; // section table start - - for _ in 0..num_sections { - let hdr = img.get(sh..sh.checked_add(40)?)?; // sizeof(IMAGE_SECTION_HEADER) - if &hdr[..8] == name.as_slice() { - let vsize = rd_u32(sh + 8)? as usize; // VirtualSize - let vaddr = rd_u32(sh + 12)? as usize; // VirtualAddress = offset in loaded image - if vaddr >= img.len() { - return None; - } - return Some((vaddr, vsize.min(img.len() - vaddr))); - } - sh += 40; - } - None -} diff --git a/crates/stage0/src/http.rs b/crates/stage0/src/http.rs deleted file mode 100644 index 0d5636b..0000000 --- a/crates/stage0/src/http.rs +++ /dev/null @@ -1,146 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -//! Minimal HTTP/1.1 client for stage0, built directly on the raw `EFI_TCP4` -//! transport ([`crate::tcp4`]) plus `EFI_DNS4` resolution ([`crate::dns4`]), not -//! `EFI_HTTP`/HttpDxe. HttpDxe is avoided for two reasons: it does not drain a -//! multi-segment response body (see `tcp4.rs`), and as the optional HTTP-Boot -//! driver it is the network protocol least likely to be present on a given -//! firmware (e.g. Azure's). It also layers on TCP4/DNS4, so depending on those -//! directly is the more portable subset. TLS is intentionally not handled: stage0 -//! admits payloads by pinned sha256 / ed25519, so transport security is not -//! load-bearing. Network bring-up (drivers + DHCP) lives in [`crate::net`]. - -use alloc::string::String; -use alloc::vec::Vec; - -use uefi::Status; - -use crate::tcp4; - -/// HTTP request method. Only GET/PUT are used (the IMDSv2 token fetch is a PUT). -#[derive(Clone, Copy, Debug)] -pub enum HttpMethod { - Get, - Put, -} - -impl HttpMethod { - fn as_str(self) -> &'static str { - match self { - HttpMethod::Get => "GET", - HttpMethod::Put => "PUT", - } - } -} - -/// `true` for a 2xx status code. -#[must_use] -pub fn is_ok(status: u16) -> bool { - (200..300).contains(&status) -} - -/// Perform one HTTP/1.1 request over TCP4 and return `(status, body)`. A hostname -/// is resolved via `EFI_DNS4`; an IPv4 literal connects directly. The request asks -/// for `Connection: close`, so the body is delimited by the peer closing. A `Host` -/// header in `headers` overrides the URL-derived one (used for GCP metadata). -pub fn fetch( - method: HttpMethod, - url: &str, - headers: &[(&str, &str)], -) -> Result<(u16, Vec), Status> { - let (host, port, path) = parse_http_url(url).ok_or_else(|| { - crate::slog!("stage0: unsupported URL (need http://host[:port]/path): {url}"); - Status::INVALID_PARAMETER - })?; - - let ip = match parse_ipv4(host) { - Some(ip) => ip, - None => crate::dns4::resolve(host)?, - }; - - let mut req = String::new(); - req.push_str(method.as_str()); - req.push(' '); - req.push_str(path); - req.push_str(" HTTP/1.1\r\n"); - // Caller's Host wins (servers reject requests without one); else derive it. - if !headers.iter().any(|(n, _)| n.eq_ignore_ascii_case("host")) { - req.push_str("Host: "); - req.push_str(host); - req.push_str("\r\n"); - } - for (name, value) in headers { - req.push_str(name); - req.push_str(": "); - req.push_str(value); - req.push_str("\r\n"); - } - req.push_str("Connection: close\r\nUser-Agent: stage0\r\n\r\n"); - crate::sdbg!("stage0: HTTP {} {url}", method.as_str()); - - let raw = tcp4::exchange(ip, port, req.as_bytes())?; - - let sep = find_subslice(&raw, b"\r\n\r\n").ok_or_else(|| { - crate::slog!("stage0: response had no header terminator"); - Status::PROTOCOL_ERROR - })?; - let status = parse_status_code(&raw[..sep]).ok_or_else(|| { - crate::slog!("stage0: could not parse HTTP status line"); - Status::PROTOCOL_ERROR - })?; - let body = raw[sep + 4..].to_vec(); - crate::sdbg!("stage0: response {status}, {} body bytes", body.len()); - Ok((status, body)) -} - -/// GET `url`, require a 2xx status, and return the body. Used for the payload. -pub fn download(url: &str) -> Result, Status> { - let (status, body) = fetch(HttpMethod::Get, url, &[])?; - if !is_ok(status) { - crate::slog!("stage0: download got non-2xx status {status}"); - return Err(Status::ABORTED); - } - Ok(body) -} - -/// Parse the numeric status from an `HTTP/1.x NNN Reason` status line (the first -/// line of `head`). -fn parse_status_code(head: &[u8]) -> Option { - let line = head.split(|&b| b == b'\n').next()?; - let line = core::str::from_utf8(line).ok()?; - line.split_whitespace().nth(1)?.parse::().ok() -} - -/// Parse `http://[:port]/` → (host, port, path). The host may be an -/// IPv4 literal or a name (resolved by the caller via EFI_DNS4). -fn parse_http_url(url: &str) -> Option<(&str, u16, &str)> { - let rest = url.strip_prefix("http://")?; - let slash = rest.find('/').unwrap_or(rest.len()); - let authority = &rest[..slash]; - let path = if slash < rest.len() { - &rest[slash..] - } else { - "/" - }; - let (host, port) = match authority.split_once(':') { - Some((h, p)) => (h, p.parse::().ok()?), - None => (authority, 80), - }; - Some((host, port, path)) -} - -fn parse_ipv4(s: &str) -> Option<[u8; 4]> { - let mut octets = [0u8; 4]; - let mut parts = s.split('.'); - for o in octets.iter_mut() { - *o = parts.next()?.parse::().ok()?; - } - if parts.next().is_some() { - return None; - } - Some(octets) -} - -fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option { - haystack.windows(needle.len()).position(|w| w == needle) -} diff --git a/crates/stage0/src/main.rs b/crates/stage0/src/main.rs deleted file mode 100644 index 3fbe237..0000000 --- a/crates/stage0/src/main.rs +++ /dev/null @@ -1,234 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -//! stage0 - a measured UEFI network bootloader for the lockboot stack. -//! -//! Boots as a pure UEFI application (no Linux kernel), pulls a `_stage1` -//! user-data document from the cloud metadata service, downloads a UEFI payload -//! over raw `EFI_TCP4` (see `tcp4.rs`), admits it via one of two policies (a -//! pinned SHA-256, or an ed25519 signature against a pinned release key, see -//! `sig.rs`), measures it into the TPM via `EFI_TCG2_PROTOCOL` (PCR 14 = -//! SHA-256 of the loaded binary), then chain-loads it. -//! -//! The payload is loaded through a temporary security-arch override (`secauth.rs`) -//! rather than relying on the firmware `db`, so the deployment is not forced to -//! Secure-Boot-sign every late-bound payload. The attestation surface is kept -//! deliberately small: the only thing measured is PCR 14, meaning "stage0 ran, and -//! it loaded a binary with this hash." The admission signature/key are not measured. - -#![no_std] -#![no_main] - -extern crate alloc; - -mod config; -mod dns4; -mod embedded; -mod http; -mod metadata; -mod net; -mod secauth; -mod sig; -mod tcg2; -mod tcp4; -mod timing; - -use alloc::string::String; -use config::Verify; -use sha2::{Digest, Sha256}; -use uefi::boot; -use uefi::prelude::*; -use uefi::proto::loaded_image::LoadedImage; -use uefi::CString16; - -/// PCR extended with SHA-256 of the loaded payload (matches stage1's binary PCR). -const PCR_BINARY: u8 = 14; - -#[entry] -fn main() -> Status { - uefi::helpers::init().unwrap(); - match run() { - Ok(()) => { - crate::slog!("stage0: payload returned control to stage0 (unexpected)"); - Status::LOAD_ERROR - } - Err(status) => { - crate::slog!("stage0: ERROR {:?}", status); - // Pause so the failure is visible on the serial console. - boot::stall(5_000_000); - status - } - } -} - -fn run() -> Result<(), Status> { - // Calibrate the boot-relative clock first so every log line below is stamped. - timing::init(); - crate::slog!("stage0: version: {}", env!("CARGO_PKG_VERSION")); - - // Bring the network up once (DHCP), then fetch metadata. Metadata and payload - // both ride the raw-TCP4 HTTP client (http.rs). - let (url, verify, args) = { - net::bringup()?; - - // An embedded `_stage1` section is part of the signed, measured PE, so it - // is used in place of the cloud metadata service when present. - let json = match embedded::metadata() { - Some(j) => { - let h = hex::encode(sha256(&j)); - crate::slog!("stage0: metadata: embedded {} bytes sha256:{h}", j.len()); - j - } - None => metadata::fetch()?, - }; - let user_data = config::parse(&json).map_err(|m| { - crate::slog!("stage0: config error: {m}"); - Status::INVALID_PARAMETER - })?; - let arch = user_data.stage1.for_this_arch().ok_or_else(|| { - crate::slog!("stage0: no _stage1 config for this architecture"); - Status::UNSUPPORTED - })?; - let verify = arch.validate().map_err(|m| { - crate::slog!("stage0: invalid arch config: {m}"); - Status::INVALID_PARAMETER - })?; - (arch.url.clone(), verify, user_data.stage1.args.clone()) - }; - - // Payload download over the same raw-TCP4 HTTP client (a hostname URL is - // resolved via EFI_DNS4; an IPv4 literal connects directly). - crate::sdbg!("stage0: downloading payload from {url}"); - let binary = http::download(&url)?; - crate::slog!("stage0: payload: {} bytes from {url}", binary.len()); - - // Admission control. PCR 14 always records the SHA-256 of what we load; the - // policy below only decides whether we are *allowed* to load it. - let digest = sha256(&binary); - let hash = hex::encode(digest); - // Signed remote load options (ed25519 mode), if any, override the inline `args`. - let mut signed_args: Option = None; - match &verify { - Verify::Sha256(expected) => { - if !hash.eq_ignore_ascii_case(expected) { - crate::slog!("stage0: SHA256 mismatch! expected {expected}, got {hash}"); - return Err(Status::SECURITY_VIOLATION); - } - crate::slog!("stage0: verified: sha256:{hash} (sha256 pin)"); - } - Verify::Ed25519 { pubkey, sig_url, args_url, args_sig_url } => { - // Detached signature: the `sig_url` template with `{sha256}` replaced by - // the payload digest (content-addressable), else `.sig`. - let sig_url = match sig_url { - Some(t) => t.replace("{sha256}", &hash), - None => alloc::format!("{url}.sig"), - }; - crate::sdbg!("stage0: fetching signature from {sig_url}"); - let signature = http::download(&sig_url)?; - sig::verify(pubkey, &binary, &signature).map_err(|m| { - crate::slog!("stage0: ed25519 verification failed: {m}"); - Status::SECURITY_VIOLATION - })?; - crate::slog!("stage0: verified: sha256:{hash} (ed25519 key:{pubkey})"); - - if let Some(au) = args_url { - signed_args = Some(fetch_signed_args(au, args_sig_url.as_deref(), pubkey, &hash)?); - } - } - } - - // Measure before executing. Only PCR 14 (the binary): the config/key are not - // measured, so attestation is simply "stage0 ran and loaded this hash". - // Scoped so the TCG2 protocol is released before chain-loading: stage0 opens - // it exclusively, and the payload needs to open it too (else ACCESS_DENIED). - { - let mut tpm = tcg2::open_tpm().map_err(|e| { - crate::slog!("stage0: TPM unavailable: {e}"); - Status::DEVICE_ERROR - })?; - measure(&mut tpm, PCR_BINARY, &digest)?; - } - crate::slog!("stage0: PCR{PCR_BINARY} extended"); - - // Chain-load the measured payload from memory. The payload is admitted by - // stage0's own policy above, not the firmware db, so load it through a - // temporary security-arch override (see secauth.rs). - let image = secauth::load_image_verified(&binary).inspect_err(|&status| { - crate::slog!("stage0: load_image failed: {status:?}"); - })?; - - // Load options: signed remote args (if any) override the inline `args`. The - // backing buffer must stay alive until after start_image. - let opts = signed_args.or_else(|| { - args.as_deref().filter(|a| !a.is_empty()).map(|a| a.join(" ")) - }); - let _options = set_load_options(image, opts.as_deref()); - - crate::slog!("stage0: starting payload"); - boot::start_image(image).map_err(|e| e.status())?; - - Ok(()) -} - -/// Extend a PCR with `data` via the TCG2-backed TPM transport. -fn measure(tpm: &mut vaportpm_attest::Tpm, pcr: u8, data: &[u8]) -> Result<(), Status> { - use vaportpm_attest::PcrOps; - tpm.pcr_extend(pcr, data).map_err(|e| { - crate::slog!("stage0: pcr_extend(PCR{pcr}) failed: {e}"); - Status::DEVICE_ERROR - }) -} - -/// Fetch and verify signed load options (ed25519 mode). `args_url`/`args_sig_url` -/// may contain `{sha256}` (replaced with the payload digest). The detached -/// signature, from `args_sig_url` or `.sig`, must verify against the -/// release `pubkey`; the verified bytes are returned verbatim (trimmed) as the -/// load-options string. -fn fetch_signed_args( - args_url: &str, - args_sig_url: Option<&str>, - pubkey: &str, - payload_hash: &str, -) -> Result { - let args_url = args_url.replace("{sha256}", payload_hash); - let sig_url = match args_sig_url { - Some(s) => s.replace("{sha256}", payload_hash), - None => alloc::format!("{args_url}.sig"), - }; - crate::sdbg!("stage0: fetching signed args from {args_url}"); - let args = http::download(&args_url)?; - let sig = http::download(&sig_url)?; - sig::verify(pubkey, &args, &sig).map_err(|m| { - crate::slog!("stage0: signed args verification failed: {m}"); - Status::SECURITY_VIOLATION - })?; - let opts = core::str::from_utf8(&args) - .map_err(|_| { - crate::slog!("stage0: signed args are not valid UTF-8"); - Status::INVALID_PARAMETER - })? - .trim(); - crate::slog!("stage0: args: {} bytes signed (ed25519)", opts.len()); - Ok(opts.into()) -} - -/// Set the loaded image's load options from the final `opts` string (UCS-2). -/// Returns the backing [`CString16`], which the caller must keep alive until -/// `start_image`. -fn set_load_options(image: Handle, opts: Option<&str>) -> Option { - let opts = opts?; - if opts.is_empty() { - return None; - } - let options = CString16::try_from(opts).ok()?; - let mut loaded = boot::open_protocol_exclusive::(image).ok()?; - unsafe { - loaded.set_load_options(options.as_ptr().cast::(), options.num_bytes() as u32); - } - Some(options) -} - -fn sha256(data: &[u8]) -> [u8; 32] { - let mut hasher = Sha256::new(); - hasher.update(data); - hasher.finalize().into() -} diff --git a/crates/stage0/src/metadata.rs b/crates/stage0/src/metadata.rs deleted file mode 100644 index 53b1e11..0000000 --- a/crates/stage0/src/metadata.rs +++ /dev/null @@ -1,110 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -//! Cloud metadata-service user-data fetching. -//! -//! Mirrors `stage1`'s provider order and endpoints (EC2 IMDSv2 → GCP → Azure), -//! plus a best-effort Aliyun path, using fixed link-local IPs so no DNS is -//! needed to reach the metadata service itself. Requests go over the raw-TCP4 -//! HTTP client ([`crate::http`]); the network must be brought up first. - -use alloc::string::String; -use alloc::vec::Vec; -use base64::engine::general_purpose::STANDARD; -use base64::Engine as _; -use sha2::{Digest, Sha256}; - -use crate::http::{self, is_ok, HttpMethod}; -use uefi::Status; - -const EC2_TOKEN_URL: &str = "http://169.254.169.254/latest/api/token"; -const EC2_USERDATA_URL: &str = "http://169.254.169.254/latest/user-data"; -const GCP_USERDATA_URL: &str = - "http://169.254.169.254/computeMetadata/v1/instance/attributes/user-data"; -const AZURE_USERDATA_URL: &str = - "http://169.254.169.254/metadata/instance/compute/userData?api-version=2021-02-01&format=text"; -const ALIYUN_USERDATA_URL: &str = "http://100.100.100.200/latest/user-data"; - -/// A metadata provider: name + a fetch function returning the raw user-data. -type Provider = fn() -> Result, Status>; - -/// Try each cloud provider in turn; return the first user-data document found. -pub fn fetch() -> Result, Status> { - let providers: [(&str, Provider); 4] = [ - ("EC2 (IMDSv2)", try_ec2), - ("GCP", try_gcp), - ("Azure", try_azure), - ("Aliyun", try_aliyun), - ]; - for (name, try_fn) in providers { - crate::sdbg!("stage0: trying metadata provider: {name}"); - match try_fn() { - Ok(data) => { - let h = hex::encode(Sha256::digest(&data)); - crate::slog!("stage0: metadata: {name} {} bytes sha256:{h}", data.len()); - return Ok(data); - } - Err(e) => crate::sdbg!("stage0: {name} failed: {:?}", e), - } - } - crate::slog!("stage0: no metadata provider responded"); - Err(Status::NOT_FOUND) -} - -/// AWS EC2 IMDSv2: obtain a session token (PUT), then GET user-data. -fn try_ec2() -> Result, Status> { - let (status, token) = http::fetch( - HttpMethod::Put, - EC2_TOKEN_URL, - &[("X-aws-ec2-metadata-token-ttl-seconds", "21600")], - )?; - if !is_ok(status) { - return Err(Status::ABORTED); - } - let token = String::from_utf8(token).map_err(|_| Status::ABORTED)?; - let token = token.trim(); - - let (status, body) = http::fetch( - HttpMethod::Get, - EC2_USERDATA_URL, - &[("X-aws-ec2-metadata-token", token)], - )?; - if !is_ok(status) { - return Err(Status::ABORTED); - } - Ok(body) -} - -/// GCP compute metadata (reachable at the link-local IP; requires the flavor header). -fn try_gcp() -> Result, Status> { - let (status, body) = http::fetch( - HttpMethod::Get, - GCP_USERDATA_URL, - &[ - ("Metadata-Flavor", "Google"), - ("Host", "metadata.google.internal"), - ], - )?; - if !is_ok(status) { - return Err(Status::ABORTED); - } - Ok(body) -} - -/// Azure IMDS: user-data is returned base64-encoded. -fn try_azure() -> Result, Status> { - let (status, body) = http::fetch(HttpMethod::Get, AZURE_USERDATA_URL, &[("Metadata", "true")])?; - if !is_ok(status) { - return Err(Status::ABORTED); - } - let text = String::from_utf8(body).map_err(|_| Status::ABORTED)?; - STANDARD.decode(text.trim()).map_err(|_| Status::ABORTED) -} - -/// Aliyun ECS metadata (best-effort; v1 plain GET of user-data). -fn try_aliyun() -> Result, Status> { - let (status, body) = http::fetch(HttpMethod::Get, ALIYUN_USERDATA_URL, &[])?; - if !is_ok(status) { - return Err(Status::ABORTED); - } - Ok(body) -} diff --git a/crates/stage0/src/net.rs b/crates/stage0/src/net.rs deleted file mode 100644 index 104a3ee..0000000 --- a/crates/stage0/src/net.rs +++ /dev/null @@ -1,90 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -//! Link/IP-layer network bring-up: connect the firmware's network drivers and -//! obtain a DHCP lease, so the TCP4 transport, DNS4, and HTTP client can assume -//! the interface is addressed. Nothing here is HTTP-specific. - -use uefi::boot; -use uefi::proto::network::ip4config2::Ip4Config2; -use uefi::Status; -use uefi_raw::protocol::network::ip4_config2::Ip4Config2Policy; - -/// How often to poll for the DHCP lease. Fine-grained so bring-up returns promptly -/// (the crate's `ifup` polls at 1s granularity); small enough that the firmware's -/// IP4/DHCP timers still run during the stall. -const DHCP_POLL_INTERVAL_MS: u64 = 10; -/// Give up on DHCP after this long. -const DHCP_TIMEOUT_MS: u64 = 30_000; - -/// Bring the network up: connect the firmware's drivers, then obtain a DHCP lease. -/// Call once before any networking. -pub fn bringup() -> Result<(), Status> { - connect_all_controllers(); - let nic = boot::get_handle_for_protocol::().map_err(|e| { - crate::slog!( - "stage0: no EFI_IP4_CONFIG2 (firmware lacks the IPv4 stack?): {:?}", - e.status() - ); - e.status() - })?; - let mut ip4 = Ip4Config2::new(nic).map_err(|e| e.status())?; - dhcp_up(&mut ip4) -} - -/// Bring the interface up via DHCP and wait for the lease, polling at -/// [`DHCP_POLL_INTERVAL_MS`]. The DHCP exchange itself is firmware-paced; this just -/// returns the instant the lease lands. No-op if the interface is already addressed. -fn dhcp_up(ip4: &mut Ip4Config2) -> Result<(), Status> { - let addr = |a: uefi_raw::Ipv4Address| a.0; - let info = ip4.get_interface_info().map_err(|e| e.status())?; - if addr(info.station_addr) != [0, 0, 0, 0] { - let a = addr(info.station_addr); - crate::slog!("stage0: network: OK {}.{}.{}.{} (already up)", a[0], a[1], a[2], a[3]); - return Ok(()); - } - - ip4.set_policy(Ip4Config2Policy::DHCP).map_err(|e| { - crate::slog!("stage0: DHCP set-policy failed: {:?}", e.status()); - e.status() - })?; - - let start = crate::timing::since_boot_ms(); - loop { - boot::stall((DHCP_POLL_INTERVAL_MS * 1000) as usize); - let info = ip4.get_interface_info().map_err(|e| e.status())?; - let a = addr(info.station_addr); - if a != [0, 0, 0, 0] { - let took = crate::timing::since_boot_ms().wrapping_sub(start); - crate::slog!("stage0: network: OK {}.{}.{}.{} (DHCP {took} ms)", a[0], a[1], a[2], a[3]); - return Ok(()); - } - if crate::timing::since_boot_ms().wrapping_sub(start) >= DHCP_TIMEOUT_MS { - crate::slog!("stage0: DHCP timed out after {DHCP_TIMEOUT_MS} ms"); - return Err(Status::TIMEOUT); - } - } -} - -/// Connect all drivers to all handles (best-effort), forcing the firmware to bind -/// its network stack so the TCP4/IP4 service bindings become available even on the -/// first boot before BDS has connected everything. -fn connect_all_controllers() { - let handles = match boot::locate_handle_buffer(boot::SearchType::AllHandles) { - Ok(h) => h, - Err(e) => { - crate::slog!("stage0: locate_handle_buffer failed: {:?}", e.status()); - return; - } - }; - let mut connected = 0usize; - for handle in handles.iter() { - if boot::connect_controller(*handle, None, None, true).is_ok() { - connected += 1; - } - } - crate::sdbg!( - "stage0: connected drivers on {}/{} handles", - connected, - handles.len() - ); -} diff --git a/crates/stage0/src/secauth.rs b/crates/stage0/src/secauth.rs deleted file mode 100644 index e5e283b..0000000 --- a/crates/stage0/src/secauth.rs +++ /dev/null @@ -1,168 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -//! Loading a payload that the UEFI `db` did not sign. -//! -//! Under Secure Boot, DXE core's `LoadImage` does not decide accept/reject -//! itself; it delegates to the architectural security protocols. For a -//! memory-buffer load the authoritative gate is -//! `EFI_SECURITY2_ARCH_PROTOCOL.FileAuthentication`, whose default -//! implementation runs the `db`/`dbx` check and returns `ACCESS_DENIED` for an -//! unsigned image. Older firmware without Security2 falls back to -//! `EFI_SECURITY_ARCH_PROTOCOL.FileAuthenticationState`. -//! -//! These are plain function pointers in boot-services memory. stage0, already -//! a `db`-signed, measured image, temporarily swaps in an allow-all decision -//! around a single `LoadImage`, then restores it. This is exactly shim's -//! `security_policy_install()`/`uninstall()`: the firmware still does all the -//! real PE loading, relocation and handle setup; only the *verdict* is replaced. -//! -//! stage0 has already verified the buffer (ed25519 signature against the pinned -//! release key, or pinned SHA-256) before we get here, so the trust does not -//! weaken; it moves from the firmware `db` (which is not remotely attestable -//! and, under our ephemeral-key lockdown, cannot sign late-bound payloads) into -//! stage0's own policy. The payload is still measured into PCR 14, so the -//! attestation chain is unbroken: stage0 ran, and it loaded *this* hash. - -use uefi::boot::{ - self, LoadImageSource, OpenProtocolAttributes, OpenProtocolParams, ScopedProtocol, -}; -use uefi::proto::unsafe_protocol; -use uefi::{Handle, Status}; -use uefi_raw::Boolean; - -use core::ffi::c_void; - -// ---- EFI_SECURITY2_ARCH_PROTOCOL (94ab2f58-...) ---- - -type Security2FileAuth = unsafe extern "efiapi" fn( - this: *const c_void, // EFI_SECURITY2_ARCH_PROTOCOL* - device_path: *const c_void, // EFI_DEVICE_PATH_PROTOCOL* - file_buffer: *mut c_void, - file_size: usize, - boot_policy: Boolean, -) -> Status; - -#[repr(C)] -struct Security2Interface { - file_authentication: Security2FileAuth, -} - -#[unsafe_protocol("94ab2f58-1438-4ef1-9152-18941a3a0e68")] -struct Security2(Security2Interface); - -// ---- EFI_SECURITY_ARCH_PROTOCOL, v1 fallback (a46423e3-...) ---- - -type SecurityFileAuthState = unsafe extern "efiapi" fn( - this: *const c_void, // EFI_SECURITY_ARCH_PROTOCOL* - authentication_status: u32, - file: *const c_void, // EFI_DEVICE_PATH_PROTOCOL* -) -> Status; - -#[repr(C)] -struct SecurityInterface { - file_authentication_state: SecurityFileAuthState, -} - -#[unsafe_protocol("a46423e3-4617-49f1-b9ff-d1bfa9115839")] -struct Security(SecurityInterface); - -/// allow-all replacement for `Security2.FileAuthentication`. -unsafe extern "efiapi" fn allow_security2( - _this: *const c_void, - _device_path: *const c_void, - _file_buffer: *mut c_void, - _file_size: usize, - _boot_policy: Boolean, -) -> Status { - Status::SUCCESS -} - -/// allow-all replacement for `Security.FileAuthenticationState`. -unsafe extern "efiapi" fn allow_security( - _this: *const c_void, - _authentication_status: u32, - _file: *const c_void, -) -> Status { - Status::SUCCESS -} - -/// RAII guard: on construction, replaces the security-arch authentication hooks -/// with allow-all; on drop, restores the originals. The window is kept to a -/// single `LoadImage` call so no other image load is affected. -struct AuthOverride { - security2: Option<(ScopedProtocol, Security2FileAuth)>, - security: Option<(ScopedProtocol, SecurityFileAuthState)>, -} - -impl AuthOverride { - fn install() -> Self { - // EFI_SECURITY2_ARCH_PROTOCOL: authoritative for buffer loads on all - // modern edk2/OVMF firmware (our targets). - let security2 = open::().map(|mut sp| { - let iface: *mut Security2Interface = &mut sp.0; - let saved = unsafe { (*iface).file_authentication }; - unsafe { (*iface).file_authentication = allow_security2 }; - (sp, saved) - }); - - // EFI_SECURITY_ARCH_PROTOCOL: only consulted when Security2 is absent, - // but override it too so we behave on older firmware. - let security = open::().map(|mut sp| { - let iface: *mut SecurityInterface = &mut sp.0; - let saved = unsafe { (*iface).file_authentication_state }; - unsafe { (*iface).file_authentication_state = allow_security }; - (sp, saved) - }); - - Self { - security2, - security, - } - } -} - -impl Drop for AuthOverride { - fn drop(&mut self) { - if let Some((sp, saved)) = self.security2.as_mut() { - let iface: *mut Security2Interface = &mut sp.0; - unsafe { (*iface).file_authentication = *saved }; - } - if let Some((sp, saved)) = self.security.as_mut() { - let iface: *mut SecurityInterface = &mut sp.0; - unsafe { (*iface).file_authentication_state = *saved }; - } - } -} - -/// Open an architectural protocol non-exclusively (it lives for the life of -/// boot services; we only mutate one function pointer in it). -fn open() -> Option> { - let handle = boot::get_handle_for_protocol::

().ok()?; - unsafe { - boot::open_protocol::

( - OpenProtocolParams { - handle, - agent: boot::image_handle(), - controller: None, - }, - OpenProtocolAttributes::GetProtocol, - ) - } - .ok() -} - -/// `LoadImage` a payload from memory, bypassing the Secure Boot `db` check via a -/// temporary security-arch override. The caller MUST have already verified the -/// buffer (signature or pinned hash); this only relaxes the firmware gate. -pub fn load_image_verified(buffer: &[u8]) -> Result { - let _guard = AuthOverride::install(); - boot::load_image( - boot::image_handle(), - LoadImageSource::FromBuffer { - buffer, - file_path: None, - }, - ) - .map_err(|e| e.status()) - // `_guard` drops here, restoring the original authentication hooks. -} diff --git a/crates/stage0/src/sig.rs b/crates/stage0/src/sig.rs deleted file mode 100644 index b9a1de6..0000000 --- a/crates/stage0/src/sig.rs +++ /dev/null @@ -1,33 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -//! ed25519 admission control for "signed mode" payloads. -//! -//! In signed mode the metadata pins a long-term **release public key** (32-byte -//! ed25519, base64) instead of an exact SHA-256. The payload at the URL is -//! whatever the latest signed build is; stage0 fetches a detached signature -//! (`.sig`, 64 raw bytes) and verifies it against the pinned key before -//! loading. This lets a release roll forward without editing VM metadata. -//! -//! The signature is *admission control only*: it is not measured, and the key -//! is not measured. The attestation surface stays minimal: PCR 14 records the -//! SHA-256 of whatever binary actually ran, full stop. - -use base64::engine::general_purpose::STANDARD; -use base64::Engine as _; -use ed25519_compact::{PublicKey, Signature}; - -/// Verify a detached ed25519 `signature` over `message` against the base64 -/// `pubkey_b64` pinned in the metadata. Verification is constant-work and needs -/// no allocator or RNG. -pub fn verify(pubkey_b64: &str, message: &[u8], signature: &[u8]) -> Result<(), &'static str> { - let key_bytes = STANDARD - .decode(pubkey_b64.trim()) - .map_err(|_| "ed25519 pubkey is not valid base64")?; - let public_key = - PublicKey::from_slice(&key_bytes).map_err(|_| "ed25519 pubkey wrong length")?; - let signature = - Signature::from_slice(signature).map_err(|_| "ed25519 signature wrong length")?; - public_key - .verify(message, &signature) - .map_err(|_| "ed25519 signature verification failed") -} diff --git a/crates/stage0/src/tcg2.rs b/crates/stage0/src/tcg2.rs deleted file mode 100644 index 730bd28..0000000 --- a/crates/stage0/src/tcg2.rs +++ /dev/null @@ -1,70 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -//! TPM access from UEFI via `EFI_TCG2_PROTOCOL`. -//! -//! `vaportpm-attest` speaks raw TPM 2.0 command/response blocks through its -//! [`TpmTransport`] trait. Here we provide a transport that ships those blocks -//! over the firmware's `EFI_TCG2_PROTOCOL.SubmitCommand`, so the exact same -//! `pcr_extend` logic that `stage1` runs against `/dev/tpmrm0` on Linux runs -//! unchanged here, keeping a single measurement (and verification) model. - -use alloc::boxed::Box; -use alloc::vec; -use alloc::vec::Vec; -use anyhow::{anyhow, bail, Result}; -use uefi::boot; -use uefi::boot::ScopedProtocol; -use uefi::proto::tcg::v2::Tcg; -use vaportpm_attest::{Tpm, TpmTransport}; - -/// A [`TpmTransport`] backed by `EFI_TCG2_PROTOCOL`. -struct Tcg2Transport { - tcg: ScopedProtocol, - max_response_size: usize, -} - -impl TpmTransport for Tcg2Transport { - fn transmit_raw(&mut self, command: &[u8]) -> Result> { - // Size the response buffer to the firmware-reported maximum. - let mut out = vec![0u8; self.max_response_size.max(64)]; - self.tcg - .submit_command(command, &mut out) - .map_err(|e| anyhow!("EFI_TCG2 SubmitCommand failed: {:?}", e.status()))?; - - // The actual response length lives in the TPM response header - // (bytes 2..6, big-endian). Truncate to it. - if out.len() < 10 { - bail!("TPM response buffer too small ({} bytes)", out.len()); - } - let size = u32::from_be_bytes([out[2], out[3], out[4], out[5]]) as usize; - if size < 10 || size > out.len() { - bail!("invalid TPM response size {}", size); - } - out.truncate(size); - Ok(out) - } -} - -/// Locate the TPM and return a [`Tpm`] context bound to the TCG2 transport. -/// -/// Fails closed: if no TCG2 protocol is present or the TPM is reported absent, -/// returns an error rather than booting an unmeasured payload. -pub fn open_tpm() -> Result { - let handle = boot::get_handle_for_protocol::() - .map_err(|e| anyhow!("no EFI_TCG2_PROTOCOL: {:?}", e.status()))?; - let mut tcg = boot::open_protocol_exclusive::(handle) - .map_err(|e| anyhow!("open EFI_TCG2_PROTOCOL: {:?}", e.status()))?; - - let cap = tcg - .get_capability() - .map_err(|e| anyhow!("TCG2 get_capability: {:?}", e.status()))?; - if !cap.tpm_present() { - bail!("TCG2 reports no TPM present"); - } - let max_response_size = cap.max_response_size as usize; - - Ok(Tpm::with_transport(Box::new(Tcg2Transport { - tcg, - max_response_size, - }))) -} diff --git a/crates/stage0/src/tcp4.rs b/crates/stage0/src/tcp4.rs deleted file mode 100644 index acd3560..0000000 --- a/crates/stage0/src/tcp4.rs +++ /dev/null @@ -1,330 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -//! Raw `EFI_TCP4_PROTOCOL` transport: connect to an IPv4 host, send a request, -//! and read the whole response until the peer closes. The byte pipe the HTTP/1.1 -//! client in `http.rs` rides on; it knows nothing about HTTP. -//! -//! Do NOT replace this with `EFI_HTTP`/HttpDxe: HttpDxe does not drain a -//! multi-segment response body. The whole body arrives and is ACKed by the -//! firmware's TCP stack, but HttpDxe delivers only the first segment and never -//! returns the rest; the `Receive()` loop in [`exchange`] is the step it skips. -//! (HttpDxe also layers on TCP4/DNS4, so TCP4 alone is the more portable subset.) -//! -//! `uefi-raw` 0.11 does not expose TCP4, so the FFI bindings (UEFI spec, -//! EFI_TCP4_PROTOCOL) are defined here. - -use alloc::vec; -use alloc::vec::Vec; -use core::ffi::c_void; -use core::ptr; - -use uefi::boot::{self, OpenProtocolAttributes, OpenProtocolParams}; -use uefi::proto::unsafe_protocol; -use uefi::Status; -use uefi_raw::protocol::driver::ServiceBindingProtocol; -use uefi_raw::{Boolean, Event, Ipv4Address}; - -// ---- EFI_TCP4_PROTOCOL FFI (UEFI spec) ---- - -#[repr(C)] -struct AccessPoint { - use_default_address: Boolean, - station_address: Ipv4Address, - subnet_mask: Ipv4Address, - station_port: u16, - remote_address: Ipv4Address, - remote_port: u16, - active_flag: Boolean, -} - -#[repr(C)] -struct ConfigData { - type_of_service: u8, - time_to_live: u8, - access_point: AccessPoint, - control_option: *const c_void, -} - -#[repr(C)] -struct CompletionToken { - event: Event, - status: Status, -} - -#[repr(C)] -struct ConnectionToken { - completion_token: CompletionToken, -} - -#[repr(C)] -struct FragmentData { - fragment_length: u32, - fragment_buffer: *mut c_void, -} - -#[repr(C)] -struct TxData { - push: Boolean, - urgent: Boolean, - data_length: u32, - fragment_count: u32, - fragment_table: [FragmentData; 1], -} - -#[repr(C)] -struct RxData { - urgent_flag: Boolean, - data_length: u32, - fragment_count: u32, - fragment_table: [FragmentData; 1], -} - -#[repr(C)] -union Packet { - rx_data: *mut RxData, - tx_data: *mut TxData, -} - -#[repr(C)] -struct IoToken { - completion_token: CompletionToken, - packet: Packet, -} - -/// `EFI_TCP4_PROTOCOL` method table. Slots we don't call keep the correct -/// order/size but use a placeholder signature (never invoked). -#[repr(C)] -struct Tcp4Protocol { - get_mode_data: unsafe extern "efiapi" fn() -> Status, - configure: unsafe extern "efiapi" fn(*mut Tcp4Protocol, *const ConfigData) -> Status, - routes: unsafe extern "efiapi" fn() -> Status, - connect: unsafe extern "efiapi" fn(*mut Tcp4Protocol, *mut ConnectionToken) -> Status, - accept: unsafe extern "efiapi" fn() -> Status, - transmit: unsafe extern "efiapi" fn(*mut Tcp4Protocol, *mut IoToken) -> Status, - receive: unsafe extern "efiapi" fn(*mut Tcp4Protocol, *mut IoToken) -> Status, - close: unsafe extern "efiapi" fn() -> Status, - cancel: unsafe extern "efiapi" fn() -> Status, - poll: unsafe extern "efiapi" fn(*mut Tcp4Protocol) -> Status, -} - -#[unsafe_protocol("00720665-67eb-4a99-baf7-d3c33a1c7cc9")] -struct Tcp4Sb(ServiceBindingProtocol); - -#[unsafe_protocol("65530bc7-a359-410f-b010-5aadc7ec2b62")] -struct Tcp4(Tcp4Protocol); - -/// Drive an async token to completion by spinning on its volatile `status` and -/// pumping the driver via `Poll()`, with no inter-poll stall. The TCP4 driver only -/// services the network when `Poll()` runs, so any stall between polls throttles -/// receive throughput to ~one TCP segment per stall, so keep the spin tight. Bounded -/// by a wall-clock `budget_ms` (via the boot clock) so a wedged driver gives up. -unsafe fn pump(tcp: *mut Tcp4Protocol, status: *const Status, budget_ms: u64) -> Status { - let start = crate::timing::since_boot_ms(); - loop { - let s = ptr::read_volatile(status); - if s != Status::NOT_READY { - return s; - } - let _ = ((*tcp).poll)(tcp); - if crate::timing::since_boot_ms().wrapping_sub(start) >= budget_ms { - return Status::TIMEOUT; - } - } -} - -fn new_event() -> Result { - unsafe { - boot::create_event( - uefi::boot::EventType::empty(), - uefi::boot::Tpl::CALLBACK, - None, - None, - ) - } - .map(|e| e.as_ptr()) - .map_err(|e| e.status()) -} - -/// Connect to `ip:port`, send `request`, and read the full response until the -/// peer closes the connection (so requests must ask for `Connection: close`). -/// This is the transport primitive the HTTP client in `http.rs` builds on. -pub fn exchange(ip: [u8; 4], port: u16, request: &[u8]) -> Result, Status> { - let nic = boot::get_handle_for_protocol::().map_err(|e| { - crate::slog!("stage0: no EFI_TCP4 service binding: {:?}", e.status()); - e.status() - })?; - let mut sb = unsafe { - boot::open_protocol::( - OpenProtocolParams { - handle: nic, - agent: boot::image_handle(), - controller: None, - }, - OpenProtocolAttributes::GetProtocol, - ) - .map_err(|e| e.status())? - }; - - let mut child: uefi_raw::Handle = ptr::null_mut(); - let st = unsafe { (sb.0.create_child)(&mut sb.0, &mut child) }; - if st != Status::SUCCESS { - return Err(st); - } - let child_handle = unsafe { uefi::Handle::from_ptr(child).ok_or(Status::DEVICE_ERROR)? }; - - let result = exchange_on_child(child_handle, ip, port, request); - - // Tear the TCP4 child down regardless of outcome. - let _ = unsafe { (sb.0.destroy_child)(&mut sb.0, child) }; - result -} - -fn exchange_on_child( - child: uefi::Handle, - ip: [u8; 4], - port: u16, - request: &[u8], -) -> Result, Status> { - let mut tcp = unsafe { - boot::open_protocol::( - OpenProtocolParams { - handle: child, - agent: boot::image_handle(), - controller: None, - }, - OpenProtocolAttributes::GetProtocol, - ) - .map_err(|e| e.status())? - }; - let tcp_ptr: *mut Tcp4Protocol = &mut tcp.0; - - // Configure: default (DHCP) station address, active open to ip:port. - let cfg = ConfigData { - type_of_service: 0, - time_to_live: 64, - access_point: AccessPoint { - use_default_address: Boolean::from(true), - station_address: Ipv4Address([0, 0, 0, 0]), - subnet_mask: Ipv4Address([0, 0, 0, 0]), - station_port: 0, - remote_address: Ipv4Address(ip), - remote_port: port, - active_flag: Boolean::from(true), - }, - control_option: ptr::null(), - }; - let st = unsafe { ((*tcp_ptr).configure)(tcp_ptr, &cfg) }; - if st != Status::SUCCESS { - crate::slog!("stage0: TCP4 configure failed: {st:?}"); - return Err(st); - } - - let event = new_event()?; - let mut ct = ConnectionToken { - completion_token: CompletionToken { - event, - status: Status::NOT_READY, - }, - }; - let st = unsafe { ((*tcp_ptr).connect)(tcp_ptr, &mut ct) }; - let st = if st == Status::SUCCESS { - unsafe { pump(tcp_ptr, &ct.completion_token.status, 10_000) } - } else { - st - }; - let _ = unsafe { uefi::Event::from_ptr(event).map(boot::close_event) }; - if st != Status::SUCCESS { - crate::slog!("stage0: TCP4 connect failed: {st:?}"); - let _ = unsafe { ((*tcp_ptr).configure)(tcp_ptr, ptr::null()) }; - return Err(st); - } - crate::sdbg!( - "stage0: TCP4 connected to {}.{}.{}.{}:{}", - ip[0], ip[1], ip[2], ip[3], port - ); - - let send_res = tcp_send(tcp_ptr, request); - let recv_res = send_res.and_then(|()| tcp_recv_all(tcp_ptr)); - - // Reset the instance (also closes the connection). - let _ = unsafe { ((*tcp_ptr).configure)(tcp_ptr, ptr::null()) }; - recv_res -} - -fn tcp_send(tcp_ptr: *mut Tcp4Protocol, data: &[u8]) -> Result<(), Status> { - let mut tx = TxData { - push: Boolean::from(true), - urgent: Boolean::from(false), - data_length: data.len() as u32, - fragment_count: 1, - fragment_table: [FragmentData { - fragment_length: data.len() as u32, - fragment_buffer: data.as_ptr() as *mut c_void, - }], - }; - let event = new_event()?; - let mut tok = IoToken { - completion_token: CompletionToken { - event, - status: Status::NOT_READY, - }, - packet: Packet { tx_data: &mut tx }, - }; - let st = unsafe { ((*tcp_ptr).transmit)(tcp_ptr, &mut tok) }; - let st = if st == Status::SUCCESS { - unsafe { pump(tcp_ptr, &tok.completion_token.status, 10_000) } - } else { - st - }; - let _ = unsafe { uefi::Event::from_ptr(event).map(boot::close_event) }; - if st != Status::SUCCESS { - crate::slog!("stage0: TCP4 transmit failed: {st:?}"); - return Err(st); - } - Ok(()) -} - -fn tcp_recv_all(tcp_ptr: *mut Tcp4Protocol) -> Result, Status> { - let mut out: Vec = Vec::new(); - loop { - let mut buf = vec![0u8; 32 * 1024]; - let mut rx = RxData { - urgent_flag: Boolean::from(false), - data_length: buf.len() as u32, - fragment_count: 1, - fragment_table: [FragmentData { - fragment_length: buf.len() as u32, - fragment_buffer: buf.as_mut_ptr().cast(), - }], - }; - let event = new_event()?; - let mut tok = IoToken { - completion_token: CompletionToken { - event, - status: Status::NOT_READY, - }, - packet: Packet { rx_data: &mut rx }, - }; - let call = unsafe { ((*tcp_ptr).receive)(tcp_ptr, &mut tok) }; - let st = if call == Status::SUCCESS { - unsafe { pump(tcp_ptr, &tok.completion_token.status, 10_000) } - } else { - call - }; - let _ = unsafe { uefi::Event::from_ptr(event).map(boot::close_event) }; - - if st == Status::SUCCESS { - let got = (rx.data_length as usize).min(buf.len()); - if got == 0 { - break; - } - out.extend_from_slice(&buf[..got]); - } else { - // End of stream: peer FIN (clean), reset, or pump timeout. A truncated - // body is caught downstream by the sha256/size admission check. - crate::sdbg!("stage0: TCP4 recv: {} B total", out.len()); - break; - } - } - Ok(out) -} diff --git a/crates/stage0/src/timing.rs b/crates/stage0/src/timing.rs deleted file mode 100644 index 45be359..0000000 --- a/crates/stage0/src/timing.rs +++ /dev/null @@ -1,117 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -//! Boot-relative timestamps for stage0's log lines. -//! -//! UEFI boot services expose no monotonic millisecond clock, so we read the CPU's -//! free-running cycle counter directly (x86_64 `rdtsc`, aarch64 `cntvct_el0`) and -//! convert cycles → milliseconds with a frequency calibrated once against -//! `boot::stall`. The result is a coarse "time since [`init`]" that is plenty to -//! see which step in a pasted boot log is eating wall-clock time. -//! -//! Every stage0 log line is emitted through the [`slog!`](crate::slog) macro, -//! which prefixes the [`stamp`] below. - -use core::sync::atomic::{AtomicU64, Ordering}; - -use uefi::boot; - -/// Window the counter frequency is averaged over. Long enough that `boot::stall` -/// jitter is a small fraction (timestamps are diagnostic, not load-bearing), -/// short enough to be negligible against the events being timed. -const CALIBRATION_MS: u64 = 50; - -/// Raw counter value at [`init`], and the calibrated cycles-per-millisecond. -/// `CYCLES_PER_MS == 0` means [`init`] has not run yet. -static START: AtomicU64 = AtomicU64::new(0); -static CYCLES_PER_MS: AtomicU64 = AtomicU64::new(0); - -/// Read the CPU's free-running cycle counter. -#[cfg(target_arch = "x86_64")] -#[inline] -fn raw() -> u64 { - // SAFETY: `rdtsc` is unprivileged and always present on x86_64 UEFI hosts; it - // only reads the timestamp counter. - unsafe { core::arch::x86_64::_rdtsc() } -} - -#[cfg(target_arch = "aarch64")] -#[inline] -fn raw() -> u64 { - let v: u64; - // SAFETY: CNTVCT_EL0 is the EL0-readable virtual counter; the read is - // side-effect free. - unsafe { core::arch::asm!("mrs {}, cntvct_el0", out(reg) v, options(nomem, nostack)) }; - v -} - -#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))] -#[inline] -fn raw() -> u64 { - 0 -} - -/// Calibrate the counter frequency against a known stall and mark t = 0. Call once, -/// as early in `main`/`run` as possible. Costs a single `CALIBRATION_MS` stall, -/// which is itself counted (t = 0 is taken before it), so the first log line shows -/// roughly `CALIBRATION_MS`. -pub fn init() { - let t0 = raw(); - boot::stall((CALIBRATION_MS * 1000) as usize); // stall() takes microseconds - let t1 = raw(); - let per_ms = (t1.wrapping_sub(t0) / CALIBRATION_MS).max(1); - CYCLES_PER_MS.store(per_ms, Ordering::Relaxed); - START.store(t0, Ordering::Relaxed); -} - -/// Milliseconds since [`init`]. Returns 0 if [`init`] has not been called. -pub fn since_boot_ms() -> u64 { - let per_ms = CYCLES_PER_MS.load(Ordering::Relaxed); - if per_ms == 0 { - return 0; - } - raw().wrapping_sub(START.load(Ordering::Relaxed)) / per_ms -} - -/// A `[ S.mmm]`-style stamp (seconds.milliseconds since [`init`]). Returns a -/// `Display` wrapper so the [`slog!`](crate::slog) macro formats without allocating. -pub fn stamp() -> Stamp { - Stamp(since_boot_ms()) -} - -pub struct Stamp(u64); - -impl core::fmt::Display for Stamp { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - write!(f, "{:>5}.{:03}", self.0 / 1000, self.0 % 1000) - } -} - -/// Milestone log line with a boot-relative timestamp prefix, e.g. -/// `[ 1.234] stage0: downloading payload`. Always emitted. -#[macro_export] -macro_rules! slog { - ($($arg:tt)*) => { - uefi::println!("[{}] {}", $crate::timing::stamp(), format_args!($($arg)*)) - }; -} - -/// Verbose trace line, same format as [`slog!`] but compiled in only under the -/// `verbose` feature. Use for per-connection/per-request/per-segment detail that -/// would drown the default boot log. Errors should use `slog!`, not this. -#[cfg(feature = "verbose")] -#[macro_export] -macro_rules! sdbg { - ($($arg:tt)*) => { - uefi::println!("[{}] {}", $crate::timing::stamp(), format_args!($($arg)*)) - }; -} - -/// No-op form when the `verbose` feature is off. Still references the arguments -/// (via `format_args!`) so they don't trip unused-variable warnings. -#[cfg(not(feature = "verbose"))] -#[macro_export] -macro_rules! sdbg { - ($($arg:tt)*) => {{ - let _ = format_args!($($arg)*); - }}; -} diff --git a/tools/build-stage0/build.sh b/tools/build-stage0/build.sh deleted file mode 100755 index 016a828..0000000 --- a/tools/build-stage0/build.sh +++ /dev/null @@ -1,132 +0,0 @@ -#!/bin/bash -# Build a bootable disk image that boots stage0.efi directly (no kernel/UKI). -# -# Mirrors tools/build-uki/build.sh's ESP/disk logic, but the payload on the ESP -# is the signed stage0 UEFI application instead of a Unified Kernel Image. -# -# Requires privilege for losetup/mount (run inside the privileged build docker, -# or with sudo on the host). Reuses the Secure Boot keys from tools/build-uki/keys. -set -euox pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" - -ARCH="${ARCH:-x86_64}" -KEYDIR="${REPO_ROOT}/tools/build-uki/keys" -OUTPUT_DIR="${OUTPUT_DIR:-${SCRIPT_DIR}/${ARCH}}" -STAGE0_EFI="${STAGE0_EFI:-${OUTPUT_DIR}/stage0.efi}" - -case "${ARCH}" in - x86_64) BOOT_EFI="BOOTX64.EFI" ;; - aarch64) BOOT_EFI="BOOTAA64.EFI" ;; - *) echo "Unsupported ARCH: ${ARCH}"; exit 1 ;; -esac - -if [ ! -f "${STAGE0_EFI}" ]; then - echo "Error: stage0 EFI binary not found at ${STAGE0_EFI}" - echo "Build it first: make tools/build-stage0/${ARCH}/stage0.efi" - exit 1 -fi - -mkdir -p "${OUTPUT_DIR}" - -# --- Sign stage0.efi for Secure Boot (same db key as the UKI) --- -SIGNED_EFI="${OUTPUT_DIR}/${BOOT_EFI}" -echo "=== Signing stage0.efi ===" -sbsign --key "${KEYDIR}/db.crt.key" --cert "${KEYDIR}/db.crt" --output "${SIGNED_EFI}" "${STAGE0_EFI}" - -# --- Create the bootable GPT + FAT32 disk --- -echo "=== Creating bootable disk image ===" -EFI_SIZE_BYTES=$(stat -c%s "${SIGNED_EFI}") -EFI_SIZE_MB=$((EFI_SIZE_BYTES / 1024 / 1024 + 1)) -PARTITION_SIZE_MB=$((EFI_SIZE_MB * 3 / 2)) -if [ ${PARTITION_SIZE_MB} -lt 64 ]; then PARTITION_SIZE_MB=64; fi -DISK_SIZE_MB=$((PARTITION_SIZE_MB + 2)) - -DISK_IMAGE="${OUTPUT_DIR}/boot.disk" -dd if=/dev/zero of="${DISK_IMAGE}" bs=1M count=${DISK_SIZE_MB} status=none - -# Deterministic GUIDs/volume-id derived from the signed binary's hash. -EFI_HASH=$(sha256sum "${SIGNED_EFI}" | cut -d' ' -f1) -DISK_GUID="${EFI_HASH:0:8}-${EFI_HASH:8:4}-${EFI_HASH:12:4}-${EFI_HASH:16:4}-${EFI_HASH:20:12}" -PART_GUID="${EFI_HASH:32:8}-${EFI_HASH:36:4}-${EFI_HASH:40:4}-${EFI_HASH:44:4}-${EFI_HASH:48:12}" -VOLUME_ID="${EFI_HASH:0:8}" - -sfdisk "${DISK_IMAGE}" < "${OSREL_PATH}" - -# Copy the public Secure Boot enrollment material next to boot.disk + efi-vars so -# the GCP publisher (and manual enrollment) gets the certs in one release bundle. -# The private *.crt.key is intentionally NOT copied (ephemeral, stays in keys/). -for f in db.cer db.guid PK.cer PK.guid KEK.cer KEK.guid; do - [ -f "${KEYDIR}/${f}" ] && cp "${KEYDIR}/${f}" "${OUTPUT_DIR}/" -done - -if [ -n "${OWNER_UID:-}" ] && [ -n "${OWNER_GID:-}" ]; then - chown -R "${OWNER_UID}:${OWNER_GID}" "${OUTPUT_DIR}" -fi diff --git a/tools/build-uki/build.sh b/tools/build-uki/build.sh index 7cdf3a9..3cd44dd 100755 --- a/tools/build-uki/build.sh +++ b/tools/build-uki/build.sh @@ -257,7 +257,7 @@ MKUKI="${SCRIPT_DIR}/mkuki" rm -rf "${PLATFORM_DIR}" "${USERLAND_DIR}" # stage0 admits and loads the UKI by ed25519/sha256, bypassing the firmware db -# check (crates/stage0/src/secauth.rs), so the UKI is netboot-only — it needs no +# check (github.com/lockboot/stage0, crates/stage0/src/secauth.rs), so the UKI is netboot-only — it needs no # disk image, efi-vars, or db signature (those belong to the stage0 release). # stage0 is the ONLY db/Authenticode-signed link in the chain: the UKI is admitted # by the sha256 pinned in _stage1 (or an ed25519 .sig) plus the PCR 14 measurement, diff --git a/tools/build-uki/keys/.gitignore b/tools/build-uki/keys/.gitignore deleted file mode 100644 index 0482ae6..0000000 --- a/tools/build-uki/keys/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -*.key -*.cer -*.esl -*.guid -*.crt diff --git a/tools/build-uki/keys/Makefile b/tools/build-uki/keys/Makefile deleted file mode 100644 index 851069d..0000000 --- a/tools/build-uki/keys/Makefile +++ /dev/null @@ -1,16 +0,0 @@ -.PRECIOUS: %.key %.crt %.cer %.guid - -all: PK.cer KEK.cer db.cer - -%.crt: - openssl req -newkey rsa:4096 -nodes -keyout "$@.key" -new -x509 -sha256 -days 3650 -subj "/CN=Lock.Boot/" -out "$@" - #openssl req -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -keyout "$@.key" -new -x509 -sha256 -days 3650 -subj "/CN=Lock.Boot/" -out "$@" - -%.cer: %.crt %.guid - openssl x509 -outform DER -in "$<" -out "$@" - -%.guid: - uuidgen > "$@" - -clean: - rm -f *.crt *.key *.cer *.guid diff --git a/tools/build-uki/normalize-fat-timestamps.py b/tools/build-uki/normalize-fat-timestamps.py deleted file mode 100755 index 2b17558..0000000 --- a/tools/build-uki/normalize-fat-timestamps.py +++ /dev/null @@ -1,94 +0,0 @@ -#!/usr/bin/env python3 -""" -Normalize directory timestamps in a FAT32 filesystem to 1980-01-01 00:00:00 -for reproducible builds. Only modifies known directory entry locations. -""" -import sys -import struct - -def normalize_dir_entry(data, offset, fixed_time, fixed_date): - """Normalize a single directory entry's timestamps""" - # Creation time fine resolution (10ms units) at offset 13 - data[offset + 13] = 0x00 - # Creation time (offset 14-15) and date (offset 16-17) - struct.pack_into(' ") - sys.exit(1) - - image_path = sys.argv[1] - offset = int(sys.argv[2]) - normalize_fat_timestamps(image_path, offset) - print("FAT timestamps normalized to 1980-01-01 00:00:00") diff --git a/tools/publish/upload-uki.sh b/tools/publish.sh similarity index 95% rename from tools/publish/upload-uki.sh rename to tools/publish.sh index 51f5378..01c49bb 100755 --- a/tools/publish/upload-uki.sh +++ b/tools/publish.sh @@ -7,13 +7,13 @@ # it into PCR 14, and chain-loads it. The pin MUST be the sha256 of the FINAL # (post-sbsign) linux.efi — exactly the bytes uploaded here. # -# Usage: ./upload-uki.sh [version] +# Usage: ./publish.sh [version] # dest-uri : s3://bucket/prefix or gs://bucket/prefix # arch : x86_64 | aarch64 # version : a uki-v* release tag, or 'local' (default) to use a local build # -# Example: ./upload-uki.sh s3://lockboot/uki x86_64 uki-v0.1.0 -# ./upload-uki.sh gs://lockboot/uki aarch64 local +# Example: ./publish.sh s3://lockboot/uki x86_64 uki-v0.1.0 +# ./publish.sh gs://lockboot/uki aarch64 local set -euo pipefail diff --git a/tools/publish/azure/.gitkeep b/tools/publish/azure/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/tools/publish/ec2/create-ami.sh b/tools/publish/ec2/create-ami.sh deleted file mode 100755 index 7e51b2d..0000000 --- a/tools/publish/ec2/create-ami.sh +++ /dev/null @@ -1,279 +0,0 @@ -#!/bin/bash -# Script to upload disk image to S3 and create an AMI -# Usage: ./create-ami.sh [version] -# S3 bucket name is derived from os-release ID field -# version can be a GitHub release tag (e.g., v0.1.0) or 'local' to use locally built files - -set -euo pipefail - -if [ $# -lt 2 ]; then - echo "Usage: $0 [version]" - echo "Example: $0 us-east-1 x86_64 v0.1.0 # Use GitHub release" - echo "Example: $0 us-east-1 x86_64 local # Use local build" - exit 1 -fi - -REGION="$1" -ARCH="$2" -VERSION="${3:-local}" - -# Validate architecture -if [ "${ARCH}" != "x86_64" ] && [ "${ARCH}" != "aarch64" ]; then - echo "Error: Architecture must be either 'x86_64' or 'aarch64'" - exit 1 -fi - -# Map to EC2 architecture naming -if [ "${ARCH}" == "aarch64" ]; then - EC2_ARCH="arm64" -else - EC2_ARCH="${ARCH}" -fi - -# Download and verify from GitHub release or use local files -if [ "${VERSION}" != "local" ]; then - echo "=== Downloading release ${VERSION} from GitHub ===" - - # Create temporary directory for downloads - TEMP_DIR=$(mktemp -d) - trap "rm -rf ${TEMP_DIR}" EXIT - - # Determine GitHub repository from git remote - GH_REPO=$(git remote get-url origin | sed 's/.*github.com[:/]\(.*\)\.git/\1/' || echo "") - if [ -z "${GH_REPO}" ]; then - echo "Error: Could not determine GitHub repository" - exit 1 - fi - - echo "Repository: ${GH_REPO}" - echo "Downloading stage0-${ARCH}.zip from release ${VERSION}..." - - # The AMI boots stage0 (the firmware-admitted root of trust), so the cloud - # image is built from the stage0 release artifacts, not the UKI. Use a - # stage0-v* release tag here. - gh release download "${VERSION}" \ - --repo "${GH_REPO}" \ - --pattern "stage0-${ARCH}.zip" \ - --dir "${TEMP_DIR}" - - echo "Verifying attestation..." - # Verify the attestation using gh - gh attestation verify "${TEMP_DIR}/stage0-${ARCH}.zip" \ - --repo "${GH_REPO}" \ - || { echo "Error: Attestation verification failed"; exit 1; } - - echo "Extracting files..." - unzip -q "${TEMP_DIR}/stage0-${ARCH}.zip" -d "${TEMP_DIR}" - - # Use extracted files - WORK_DIR="${TEMP_DIR}" - echo "Using verified release files from ${VERSION}" -else - echo "=== Using local build files ===" - # Get script directory and compute repo root - SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" - WORK_DIR="${REPO_ROOT}/tools/build-stage0/${ARCH}" -fi - -IMAGE_FILE="${WORK_DIR}/boot.disk" -UEFI_DATA_FILE="${WORK_DIR}/efi-vars.aws" -OS_RELEASE_FILE="${WORK_DIR}/os-release" - -if [ ! -f "${OS_RELEASE_FILE}" ]; then - echo "Error: ${OS_RELEASE_FILE} not found" - exit 1 -fi - -if [ ! -f "${IMAGE_FILE}" ]; then - echo "Error: ${IMAGE_FILE} not found" - exit 1 -fi - -if [ ! -f "${UEFI_DATA_FILE}" ]; then - echo "Error: ${UEFI_DATA_FILE} not found" - exit 1 -fi - -# Source os-release to get ID, VERSION_ID, BUILD_ID, NAME, VERSION -source "${OS_RELEASE_FILE}" - -# Use ID from os-release as the S3 bucket name -S3_BUCKET="${ID}" - -# Compute SHA256 hash of the image file -echo "Computing SHA256 hash of ${IMAGE_FILE}..." -IMAGE_SHA256=$(sha256sum "${IMAGE_FILE}" | awk '{print $1}') - -# Construct S3 key without ID prefix (since bucket name is ID) -S3_KEY="${VERSION_ID}/uefi-bootdisk/${BUILD_ID}" - -# Create consistent naming and descriptions -AMI_NAME="${ID}-${ARCH}-${VERSION_ID}-${BUILD_ID}" -SNAPSHOT_DESC="${AMI_NAME}" -AMI_DESC="${PRETTY_NAME} build: ${BUILD_ID}" - -echo "=== Checking for existing snapshot ===" -EXISTING_SNAPSHOT=$(aws ec2 describe-snapshots \ - --region "${REGION}" \ - --owner-ids self \ - --filters "Name=tag:BuildID,Values=${BUILD_ID}" \ - --query 'Snapshots[0].SnapshotId' \ - --output text 2>/dev/null || echo "None") - -if [ "${EXISTING_SNAPSHOT}" != "None" ] && [ "${EXISTING_SNAPSHOT}" != "" ] && [ "${EXISTING_SNAPSHOT}" != "null" ]; then - echo "Found existing snapshot: ${EXISTING_SNAPSHOT}" - SNAPSHOT_ID="${EXISTING_SNAPSHOT}" -else - echo "No existing snapshot found, creating new one..." - echo "" - echo "=== Checking if image exists in S3 ===" - S3_KEY="${S3_KEY}.vmdk" - if aws s3api head-object --bucket "${S3_BUCKET}" --key "${S3_KEY}" --region "${REGION}" &>/dev/null; then - echo "Image already exists in S3: s3://${S3_BUCKET}/${S3_KEY}" - else - echo "Converting raw disk to stream-optimized VMDK (compresses sparse regions)..." - VMDK_FILE="${WORK_DIR}/boot.vmdk" - qemu-img convert -f raw -O vmdk -o subformat=streamOptimized "${IMAGE_FILE}" "${VMDK_FILE}" - RAW_SIZE=$(stat -c%s "${IMAGE_FILE}" 2>/dev/null || stat -f%z "${IMAGE_FILE}") - VMDK_SIZE=$(stat -c%s "${VMDK_FILE}" 2>/dev/null || stat -f%z "${VMDK_FILE}") - echo "Compressed: $(( RAW_SIZE / 1024 / 1024 ))MB raw -> $(( VMDK_SIZE / 1024 / 1024 ))MB vmdk" - - echo "Uploading VMDK to S3..." - echo "Bucket: s3://${S3_BUCKET}/${S3_KEY}" - aws s3 cp "${VMDK_FILE}" "s3://${S3_BUCKET}/${S3_KEY}" --region "${REGION}" - rm -f "${VMDK_FILE}" - fi - - echo "" - echo "=== Creating snapshot from S3 image ===" - - # Create containers.json for import using metadata - cat > containers.json << EOF -{ - "Description": "${SNAPSHOT_DESC}", - "Format": "vmdk", - "UserBucket": { - "S3Bucket": "${S3_BUCKET}", - "S3Key": "${S3_KEY}" - } -} -EOF - - # Import snapshot - echo "Importing snapshot..." - IMPORT_TASK_ID=$(aws ec2 import-snapshot \ - --region "${REGION}" \ - --disk-container "file://containers.json" \ - --query 'ImportTaskId' \ - --output text) - - rm containers.json - - echo "Import task ID: ${IMPORT_TASK_ID}" - echo "Waiting for snapshot import to complete..." - - # Wait for import to complete - while true; do - STATUS=$(aws ec2 describe-import-snapshot-tasks \ - --region "${REGION}" \ - --import-task-ids "${IMPORT_TASK_ID}" \ - --query 'ImportSnapshotTasks[0].SnapshotTaskDetail.Status' \ - --output text) - - if [ "${STATUS}" = "completed" ]; then - SNAPSHOT_ID=$(aws ec2 describe-import-snapshot-tasks \ - --region "${REGION}" \ - --import-task-ids "${IMPORT_TASK_ID}" \ - --query 'ImportSnapshotTasks[0].SnapshotTaskDetail.SnapshotId' \ - --output text) - echo "Snapshot created: ${SNAPSHOT_ID}" - - # Tag the snapshot for easier identification - echo "Tagging snapshot..." - aws ec2 create-tags \ - --region "${REGION}" \ - --resources "${SNAPSHOT_ID}" \ - --tags "Key=Name,Value=${SNAPSHOT_DESC}" \ - "Key=BuildID,Value=${BUILD_ID}" \ - "Key=VersionID,Value=${VERSION_ID}" - break - elif [ "${STATUS}" = "deleted" ] || [ "${STATUS}" = "deleting" ]; then - echo "Error: Import task failed or was deleted" - exit 1 - fi - - echo "Status: ${STATUS} - waiting..." - sleep 10 - done -fi - -# Check for existing AMI -echo "" -echo "=== Checking for existing AMI ===" -EXISTING_AMI=$(aws ec2 describe-images \ - --region "${REGION}" \ - --owners self \ - --filters "Name=name,Values=${AMI_NAME}" \ - --query 'Images[0].ImageId' \ - --output text 2>/dev/null || echo "None") - -if [ "${EXISTING_AMI}" != "None" ] && [ "${EXISTING_AMI}" != "" ] && [ "${EXISTING_AMI}" != "null" ]; then - echo "Found existing AMI: ${EXISTING_AMI}" - AMI_ID="${EXISTING_AMI}" -else - echo "No existing AMI found, registering new one..." - - # Register AMI from snapshot - echo "Registering AMI from snapshot..." - AMI_ID=$(aws ec2 register-image \ - --region "${REGION}" \ - --name "${AMI_NAME}" \ - --description "${AMI_DESC}" \ - --architecture "${EC2_ARCH}" \ - --root-device-name /dev/xvda \ - --boot-mode uefi \ - --uefi-data "$(cat ${UEFI_DATA_FILE})" \ - --tpm-support v2.0 \ - --imds-support v2.0 \ - --virtualization-type hvm \ - --ena-support \ - --block-device-mappings "DeviceName=/dev/xvda,Ebs={SnapshotId=${SNAPSHOT_ID}}" \ - --query 'ImageId' \ - --output text) - - echo "AMI registered: ${AMI_ID}" - - # Tag the AMI for easier identification - echo "Tagging AMI..." - aws ec2 create-tags \ - --region "${REGION}" \ - --resources "${AMI_ID}" \ - --tags "Key=Name,Value=${PRETTY_NAME}" \ - "Key=BuildID,Value=${BUILD_ID}" \ - "Key=VersionID,Value=${VERSION_ID}" -fi - -echo "" -echo "=== AMI Created Successfully ===" -echo "AMI ID: ${AMI_ID}" -echo "Region: ${REGION}" -echo "Architecture: ${EC2_ARCH}" -echo "" - -# Suggest appropriate instance type based on architecture -if [ "${ARCH}" == "aarch64" ]; then - INSTANCE_TYPE="c7g.medium" -else - INSTANCE_TYPE="c6i.large" -fi - -echo "Launch an instance:" -echo " aws ec2 run-instances --image-id ${AMI_ID} --instance-type ${INSTANCE_TYPE} --region ${REGION} --user-data file://config.json" -echo "" -echo "Launch with spot pricing (up to 90% cheaper):" -echo " aws ec2 run-instances --image-id ${AMI_ID} --instance-type ${INSTANCE_TYPE} --region ${REGION} --user-data file://config.json \\" -echo " --instance-market-options '{\"MarketType\":\"spot\",\"SpotOptions\":{\"SpotInstanceType\":\"one-time\"}}'" -echo "" -echo "Get serial console output:" -echo " aws ec2 get-console-output --output text --latest --region ${REGION} --instance-id " diff --git a/tools/publish/ec2/create-vmimport-role.sh b/tools/publish/ec2/create-vmimport-role.sh deleted file mode 100755 index db45029..0000000 --- a/tools/publish/ec2/create-vmimport-role.sh +++ /dev/null @@ -1,92 +0,0 @@ -#!/bin/bash -# Script to create the vmimport IAM role required for AWS VM Import/Export -# This is a one-time setup per AWS account -# Usage: ./create-vmimport-role.sh - -set -euo pipefail - -if [ $# -lt 1 ]; then - echo "Usage: $0 " - echo "Example: $0 lockboot" - exit 1 -fi - -S3_BUCKET="$1" - -echo "Creating vmimport IAM role for S3 bucket: ${S3_BUCKET}" - -# Create temporary files with templates -TRUST_POLICY=$(mktemp trust-policy.tmp.XXXXXXXXXX) -ROLE_POLICY=$(mktemp role-policy.tmp.XXXXXXXXXX) -CREATE_OUTPUT=$(mktemp create-output.tmp.XXXXXXXXXX) - -# Cleanup temp files on exit -trap "rm -f ${TRUST_POLICY} ${ROLE_POLICY} ${CREATE_OUTPUT}" EXIT - -# Create trust policy -cat > "${TRUST_POLICY}" << 'EOF' -{ - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Principal": { "Service": "vmie.amazonaws.com" }, - "Action": "sts:AssumeRole", - "Condition": { - "StringEquals":{ - "sts:Externalid": "vmimport" - } - } - } - ] -} -EOF - -# Create role policy with the specified bucket -cat > "${ROLE_POLICY}" << EOF -{ - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": [ - "s3:GetBucketLocation", - "s3:GetObject", - "s3:ListBucket" - ], - "Resource": [ - "arn:aws:s3:::${S3_BUCKET}", - "arn:aws:s3:::${S3_BUCKET}/*" - ] - }, - { - "Effect": "Allow", - "Action": [ - "ec2:ModifySnapshotAttribute", - "ec2:CopySnapshot", - "ec2:RegisterImage", - "ec2:Describe*" - ], - "Resource": "*" - } - ] -} -EOF - -echo "" -echo "Creating IAM role 'vmimport'..." -if aws iam create-role --role-name vmimport --assume-role-policy-document "file://${TRUST_POLICY}" 2>&1 | tee "${CREATE_OUTPUT}" | grep -q "EntityAlreadyExists"; then - echo "Role 'vmimport' already exists" -else - echo "Role 'vmimport' created successfully" -fi - -echo "" -echo "Attaching role policy..." -aws iam put-role-policy --role-name vmimport --policy-name vmimport --policy-document "file://${ROLE_POLICY}" - -echo "" -echo "=== vmimport role setup complete ===" -echo "Role ARN: $(aws iam get-role --role-name vmimport --query 'Role.Arn' --output text)" -echo "" -echo "You can now use create-ami.sh to import disk images from s3://${S3_BUCKET}" diff --git a/tools/publish/gcp/.gitignore b/tools/publish/gcp/.gitignore deleted file mode 100644 index 1e86b33..0000000 --- a/tools/publish/gcp/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -# Local gcloud SDK installation -google-cloud-sdk/ -google-cloud-cli-*.tar.gz -gcloud diff --git a/tools/publish/gcp/NOTES.md b/tools/publish/gcp/NOTES.md deleted file mode 100644 index c9dee80..0000000 --- a/tools/publish/gcp/NOTES.md +++ /dev/null @@ -1,151 +0,0 @@ -# GCP Confidential VM Image Publishing - -## Trust Model Requirements - -**CRITICAL**: Live migration is **DISABLED** for Confidential VMs. Live migration would break the trust model by allowing the hypervisor to access decrypted memory during migration. - -- `--maintenance-policy=TERMINATE` is **REQUIRED** -- Never use `SEV_LIVE_MIGRATABLE` guest OS feature -- Instances will terminate (not migrate) during host maintenance - -## Confidential VM Technologies - -### x86_64 -- **AMD SEV** (1st gen): Memory encryption with single key -- **AMD SEV-SNP** (Secure Nested Paging): Memory encryption + integrity protection -- **Intel TDX** (Trust Domain Extensions): Intel's confidential computing technology - -GCP automatically selects based on: -- Machine type (N2D = SEV, C2D/N2D = SEV-SNP, C3 = TDX) -- Region/zone availability - -### ARM64 (aarch64) -- Uses ARM TrustZone-based confidential computing -- No special guest OS features required -- Automatically enabled with `--confidential-compute` flag - -## Image Creation Workflow - -**Two-step approach** (required for custom guest OS features): - -```bash -# Use the provided script -./create-image.sh [version] - -# Example: -./create-image.sh my-gcp-project x86_64 v0.1.0 -./create-image.sh my-gcp-project x86_64 local -``` - -### What it does internally - -1. Uploads the disk image to Google Cloud Storage (GCS) -2. Creates custom image from GCS with specific guest OS features -3. Reuses existing GCS upload if it already exists (idempotent) - -**Why not use `gcloud compute images import`?** -The import command requires a predefined `--os` type (like "ubuntu-2204") and doesn't support custom guest OS features for custom UKI images. We need full control over UEFI_COMPATIBLE, SEV_CAPABLE, etc. - -### Manual approach (if needed) - -For **x86_64** (SEV/SEV-SNP/TDX): -```bash -# Upload to GCS -gcloud storage cp boot.disk gs://${BUCKET}/${PATH} - -# Create image with custom features -gcloud compute images create ${IMAGE_NAME} \ - --source-uri=gs://${BUCKET}/${PATH} \ - --guest-os-features=UEFI_COMPATIBLE,SEV_CAPABLE,SEV_SNP_CAPABLE,GVNIC \ - --family=${IMAGE_FAMILY} -``` - -For **aarch64**: -```bash -# Upload to GCS -gcloud storage cp boot.disk gs://${BUCKET}/${PATH} - -# Create image with custom features -gcloud compute images create ${IMAGE_NAME} \ - --source-uri=gs://${BUCKET}/${PATH} \ - --guest-os-features=UEFI_COMPATIBLE,GVNIC \ - --family=${IMAGE_FAMILY} -``` - -### Guest OS Features Explained - -- `UEFI_COMPATIBLE` - **REQUIRED** for UEFI boot -- `SEV_CAPABLE` - Mark as compatible with AMD SEV Confidential VMs -- `SEV_SNP_CAPABLE` - Mark as compatible with AMD SEV-SNP Confidential VMs (recommended) -- `GVNIC` - Use Google Virtual NIC (better performance, recommended) -- ~~`SEV_LIVE_MIGRATABLE`~~ - **NEVER USE** (breaks trust model) - -## Instance Creation - -### Quick Launch (Recommended) - -Use the provided launch script which has all Confidential VM settings baked in: - -```bash -# Launch with required user-data configuration -./launch-instance.sh my-vm us-central1-a n2d-standard-2 lockboot-x86_64 config.json - -# With additional network settings -./launch-instance.sh my-vm us-central1-a t2a-standard-1 lockboot-aarch64 config.json \ - --network-interface=network=my-vpc,subnet=my-subnet -``` - -**Note**: The `config.json` user-data file is **REQUIRED** - it contains the lockboot configuration for stage2 download and verification. - -The script automatically: -- Uses your default gcloud project -- Validates user-data file exists -- Validates machine type supports Confidential Compute -- Sets all required security flags -- Prevents accidental live migration - -### Manual Instance Creation - -```bash -gcloud compute instances create ${INSTANCE_NAME} \ - --zone=${ZONE} \ - --machine-type=${MACHINE_TYPE} \ - --image=${IMAGE_NAME} \ - --confidential-compute \ - --maintenance-policy=TERMINATE \ - --shielded-secure-boot \ - --shielded-vtpm \ - --shielded-integrity-monitoring \ - --metadata-from-file=user-data=config.json -``` - -### Machine Type Selection (x86_64) - -For **SEV-SNP** (recommended): -- N2D series: `n2d-standard-*` (AMD Milan) -- C2D series: `c2d-standard-*` (AMD Milan, compute-optimized) - -For **TDX** (Intel): -- C3 series: `c3-standard-*` (Intel Sapphire Rapids) - -For **ARM64**: -- T2A series: `t2a-standard-*` (Ampere Altra) - -### Required Flags Explained - -- `--confidential-compute` - **REQUIRED** enables memory encryption -- `--maintenance-policy=TERMINATE` - **REQUIRED** prevents live migration -- `--shielded-secure-boot` - Enables UEFI Secure Boot validation -- `--shielded-vtpm` - Provides virtual TPM 2.0 (measured boot) -- `--shielded-integrity-monitoring` - Baseline integrity measurement - -## Key Differences from AWS - -| Feature | AWS (Nitro) | GCP (Confidential VM) | -|---------|-------------|----------------------| -| UEFI vars | Provided via `--uefi-data` | Managed by platform | -| vTPM | Enabled via `--tpm-support v2.0` | Automatic with `--shielded-vtpm` | -| Memory encryption | Nitro Enclaves | SEV/SEV-SNP/TDX | -| Metadata | IMDSv2 | Metadata server | -| User data | `--user-data` | `--metadata-from-file=user-data=` | -| User data required | Optional | **Required** (stage1 config) | diff --git a/tools/publish/gcp/create-image.sh b/tools/publish/gcp/create-image.sh deleted file mode 100755 index 97acc34..0000000 --- a/tools/publish/gcp/create-image.sh +++ /dev/null @@ -1,284 +0,0 @@ -#!/bin/bash -# Script to create a GCP custom image from disk image (one-shot) -# Usage: ./create-image.sh [version] -# version can be a GitHub release tag (e.g., v0.1.0) or 'local' to use locally built files - -set -euo pipefail - -# Find gcloud: check local installation first, then system -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -if [ -x "${SCRIPT_DIR}/gcloud" ]; then - GCLOUD="${SCRIPT_DIR}/gcloud" -elif [ -x "${SCRIPT_DIR}/google-cloud-sdk/bin/gcloud" ]; then - GCLOUD="${SCRIPT_DIR}/google-cloud-sdk/bin/gcloud" -elif command -v gcloud &> /dev/null; then - GCLOUD="gcloud" -else - echo "Error: gcloud not found. Install it with:" - echo " ./install-gcloud.sh" - exit 1 -fi - -if [ $# -lt 2 ]; then - echo "Usage: $0 [version]" - echo "Example: $0 my-gcp-project x86_64 v0.1.0 # Use GitHub release" - echo "Example: $0 my-gcp-project x86_64 local # Use local build" - exit 1 -fi - -PROJECT="$1" -ARCH="$2" -VERSION="${3:-local}" - -# Validate architecture -if [ "${ARCH}" != "x86_64" ] && [ "${ARCH}" != "aarch64" ]; then - echo "Error: Architecture must be either 'x86_64' or 'aarch64'" - exit 1 -fi - -# Map to GCP architecture naming -if [ "${ARCH}" == "aarch64" ]; then - GCP_ARCH="ARM64" -else - GCP_ARCH="X86_64" -fi - -# Download and verify from GitHub release or use local files -if [ "${VERSION}" != "local" ]; then - echo "=== Downloading release ${VERSION} from GitHub ===" - - # Create temporary directory for downloads - TEMP_DIR=$(mktemp -d) - trap "rm -rf ${TEMP_DIR}" EXIT - - # Determine GitHub repository from git remote - GH_REPO=$(git remote get-url origin | sed 's/.*github.com[:/]\(.*\)\.git/\1/' || echo "") - if [ -z "${GH_REPO}" ]; then - echo "Error: Could not determine GitHub repository" - exit 1 - fi - - echo "Repository: ${GH_REPO}" - echo "Downloading stage0-${ARCH}.zip from release ${VERSION}..." - - # The image boots stage0 (the firmware-admitted root of trust), so it is built - # from the stage0 release artifacts (boot.disk + Secure Boot certs). Use a - # stage0-v* release tag here. - gh release download "${VERSION}" \ - --repo "${GH_REPO}" \ - --pattern "stage0-${ARCH}.zip" \ - --dir "${TEMP_DIR}" - - echo "Verifying attestation..." - # Verify the attestation using gh - gh attestation verify "${TEMP_DIR}/stage0-${ARCH}.zip" \ - --repo "${GH_REPO}" \ - || { echo "Error: Attestation verification failed"; exit 1; } - - echo "Extracting files..." - unzip -q "${TEMP_DIR}/stage0-${ARCH}.zip" -d "${TEMP_DIR}" - - # Use extracted files - WORK_DIR="${TEMP_DIR}" - echo "Using verified release files from ${VERSION}" -else - echo "=== Using local build files ===" - # Get script directory and compute repo root - SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" - WORK_DIR="${REPO_ROOT}/tools/build-stage0/${ARCH}" -fi - -IMAGE_FILE="${WORK_DIR}/boot.disk" -OS_RELEASE_FILE="${WORK_DIR}/os-release" - -# Get keys directory: from extracted release (flat) or from repo (keys/ subdir) -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" -if [ -f "${WORK_DIR}/db.cer" ]; then - KEYS_DIR="${WORK_DIR}" -else - KEYS_DIR="${REPO_ROOT}/tools/build-uki/keys" -fi - -if [ ! -f "${OS_RELEASE_FILE}" ]; then - echo "Error: ${OS_RELEASE_FILE} not found" - exit 1 -fi - -if [ ! -f "${IMAGE_FILE}" ]; then - echo "Error: ${IMAGE_FILE} not found" - exit 1 -fi - -# Check for UEFI Secure Boot keys -if [ ! -f "${KEYS_DIR}/PK.cer" ] || [ ! -f "${KEYS_DIR}/KEK.cer" ] || [ ! -f "${KEYS_DIR}/db.cer" ]; then - echo "Error: UEFI Secure Boot keys not found in ${KEYS_DIR}" - echo "Required: PK.cer, KEK.cer, db.cer" - exit 1 -fi - -# Source os-release to get ID, VERSION_ID, BUILD_ID, NAME, VERSION -source "${OS_RELEASE_FILE}" - -# Create simple image name: lockboot-x86-64-26-02-al2023 -# (Only one release per month, so BUILD_ID hash not needed in name) -# GCP image names: lowercase, numbers, hyphens only (no underscores) -VERSION_DASH=$(echo "${VERSION_ID}" | tr '.' '-') -ARCH_DASH=$(echo "${ARCH}" | tr '_' '-') -IMAGE_NAME="${ID}-${ARCH_DASH}-${VERSION_DASH}" -IMAGE_DESC="${PRETTY_NAME} version: ${VERSION_ID} build: ${BUILD_ID} arch: ${ARCH}" -IMAGE_FAMILY="${ID}" # Just "lockboot" - family groups all architectures together - -# Map to GCP architecture naming for --architecture flag -if [ "${ARCH}" == "aarch64" ]; then - GCP_ARCH="ARM64" -else - GCP_ARCH="X86_64" -fi - -# Set guest OS features based on architecture -if [ "${ARCH}" == "x86_64" ]; then - # x86_64: Enable SEV and SEV-SNP for Confidential VMs - # GVNIC required for Confidential Compute (gve driver needed in kernel) - GUEST_OS_FEATURES="UEFI_COMPATIBLE,SEV_CAPABLE,SEV_SNP_CAPABLE,GVNIC" -else - # aarch64: UEFI + GVNIC (Confidential Compute automatic) - GUEST_OS_FEATURES="UEFI_COMPATIBLE,GVNIC" -fi - -echo "" -echo "=== Checking for existing image ===" -EXISTING_IMAGE=$(${GCLOUD} compute images list \ - --project="${PROJECT}" \ - --filter="name=${IMAGE_NAME}" \ - --format="value(name)" \ - 2>/dev/null || echo "") - -if [ -n "${EXISTING_IMAGE}" ]; then - echo "Found existing image: ${EXISTING_IMAGE}" - echo "Image already exists, skipping creation" - IMAGE_FINAL="${EXISTING_IMAGE}" -else - echo "No existing image found, creating new one..." - echo "" - - # Use bucket name from os-release ID - GCS_BUCKET="${ID}" - # GCP requires .tar.gz format containing disk.raw - # Path: gs://lockboot/26.02.al2023/kernel-...-7f25e43a.tar.gz - GCS_PATH="${VERSION_ID}/${BUILD_ID}.tar.gz" - GCS_URI="gs://${GCS_BUCKET}/${GCS_PATH}" - - echo "=== Checking/Creating Google Cloud Storage Bucket ===" - echo "Bucket: ${GCS_BUCKET}" - echo "" - - # Check if bucket exists, create if not - if ${GCLOUD} storage buckets describe "gs://${GCS_BUCKET}" --project="${PROJECT}" &>/dev/null; then - echo "Bucket already exists: gs://${GCS_BUCKET}" - else - # Get default region from gcloud config (falls back to compute/region or us-central1) - DEFAULT_LOCATION=$(${GCLOUD} config get-value compute/region 2>/dev/null || echo "us-central1") - echo "Creating bucket: gs://${GCS_BUCKET} in ${DEFAULT_LOCATION}" - ${GCLOUD} storage buckets create "gs://${GCS_BUCKET}" \ - --project="${PROJECT}" \ - --location="${DEFAULT_LOCATION}" \ - --uniform-bucket-level-access - fi - - echo "" - echo "=== Preparing disk image for GCP ===" - echo "GCP requires: .tar.gz containing disk.raw" - echo "" - - # Create temporary tar.gz if it doesn't exist in GCS - if ${GCLOUD} storage ls "${GCS_URI}" --project="${PROJECT}" &>/dev/null; then - echo "Image already exists in GCS: ${GCS_URI}" - else - # Create tar.gz with disk.raw inside (GCP requirement) - # Use --format=oldgnu and -S for sparse file handling - TEMP_DIR=$(mktemp -d) - trap "rm -rf ${TEMP_DIR}" EXIT - - echo "Creating tar.gz with disk.raw inside..." - cp "${IMAGE_FILE}" "${TEMP_DIR}/disk.raw" - - echo "Compressing (this may take a minute)..." - tar --format=oldgnu -Sczf "${TEMP_DIR}/${BUILD_ID}.tar.gz" -C "${TEMP_DIR}" disk.raw - - echo "" - echo "=== Uploading to Google Cloud Storage ===" - echo "Path: ${GCS_PATH}" - echo "" - - echo "Uploading ${BUILD_ID}.tar.gz to ${GCS_URI} with metadata..." - # Upload with metadata from os-release - ${GCLOUD} storage cp "${TEMP_DIR}/${BUILD_ID}.tar.gz" "${GCS_URI}" \ - --project="${PROJECT}" \ - --custom-metadata="version-id=${VERSION_ID},build-id=${BUILD_ID},name=${NAME},pretty-name=${PRETTY_NAME},id=${ID},arch=${ARCH}" - fi - - echo "" - echo "=== Creating GCP Image from GCS ===" - echo "Image name: ${IMAGE_NAME}" - echo "Family: ${IMAGE_FAMILY}" - echo "Architecture: ${GCP_ARCH}" - echo "Guest OS features: ${GUEST_OS_FEATURES}" - echo "UEFI Secure Boot: Custom keys (PK, KEK, db)" - echo "" - - # Create image from GCS with custom guest OS features and Secure Boot keys - # Note: Full build details are in the image name and description - ${GCLOUD} compute images create "${IMAGE_NAME}" \ - --project="${PROJECT}" \ - --source-uri="${GCS_URI}" \ - --guest-os-features="${GUEST_OS_FEATURES}" \ - --architecture="${GCP_ARCH}" \ - --family="${IMAGE_FAMILY}" \ - --description="${IMAGE_DESC}" \ - --platform-key-file="${KEYS_DIR}/PK.cer" \ - --key-exchange-key-file="${KEYS_DIR}/KEK.cer" \ - --signature-database-file="${KEYS_DIR}/db.cer" - - IMAGE_FINAL="${IMAGE_NAME}" -fi - -echo "" -echo "=== Image Created Successfully ===" -echo "Image: ${IMAGE_FINAL}" -echo "Project: ${PROJECT}" -echo "Family: ${IMAGE_FAMILY}" -echo "Architecture: ${GCP_ARCH}" -echo "" - -# Get default zone from gcloud config -DEFAULT_ZONE=$(${GCLOUD} config get-value compute/zone 2>/dev/null || echo "us-central1-a") - -# Suggest appropriate machine type based on architecture -if [ "${ARCH}" == "aarch64" ]; then - MACHINE_TYPE="t2a-standard-1" - TECH="ARM TrustZone" -else - MACHINE_TYPE="n2d-standard-2" - TECH="AMD SEV-SNP" -fi - -echo "Launch a Confidential VM instance using:" -echo "" -echo " ./launch-instance.sh my-instance ${DEFAULT_ZONE} ${MACHINE_TYPE} ${IMAGE_FINAL} config.json" -echo "" -echo "Or use gcloud directly:" -echo " ${GCLOUD} compute instances create my-instance \\" -echo " --zone=${DEFAULT_ZONE} \\" -echo " --machine-type=${MACHINE_TYPE} \\" -echo " --image=${IMAGE_FINAL} \\" -echo " --metadata-from-file=user-data=config.json \\" -echo " --confidential-compute-type=SEV \\" -echo " --maintenance-policy=TERMINATE \\" -echo " --shielded-secure-boot \\" -echo " --shielded-vtpm \\" -echo " --shielded-integrity-monitoring" -echo "" -echo "Confidential compute technology: ${TECH}" -echo "Default zone: ${DEFAULT_ZONE} (configure with: ${GCLOUD} config set compute/zone )" diff --git a/tools/publish/gcp/get-console.sh b/tools/publish/gcp/get-console.sh deleted file mode 100755 index 7245ae9..0000000 --- a/tools/publish/gcp/get-console.sh +++ /dev/null @@ -1,94 +0,0 @@ -#!/bin/bash -# Script to fetch serial console output from a Confidential VM instance -# Usage: ./get-console.sh [options] - -set -euo pipefail - -# Find gcloud: check local installation first, then system -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -if [ -x "${SCRIPT_DIR}/gcloud" ]; then - GCLOUD="${SCRIPT_DIR}/gcloud" -elif [ -x "${SCRIPT_DIR}/google-cloud-sdk/bin/gcloud" ]; then - GCLOUD="${SCRIPT_DIR}/google-cloud-sdk/bin/gcloud" -elif command -v gcloud &> /dev/null; then - GCLOUD="gcloud" -else - echo "Error: gcloud not found. Install it with:" - echo " ./install-gcloud.sh" - exit 1 -fi - -if [ $# -lt 2 ]; then - echo "Usage: $0 [--follow|--tail N]" - echo "" - echo "Examples:" - echo " # Get full console output" - echo " $0 my-vm us-central1-a" - echo "" - echo " # Get last 50 lines" - echo " $0 my-vm us-central1-a --tail 50" - echo "" - echo " # Follow console output (poll every 2 seconds)" - echo " $0 my-vm us-central1-a --follow" - echo "" - echo "Useful for viewing stage2 attestation output dumped to console" - echo "" - echo "Documentation:" - echo " https://cloud.google.com/compute/docs/troubleshooting/viewing-serial-port-output" - exit 1 -fi - -INSTANCE_NAME="$1" -ZONE="$2" -MODE="${3:-full}" - -# Get current project -PROJECT=$(${GCLOUD} config get-value project 2>/dev/null || echo "") -if [ -z "${PROJECT}" ]; then - echo "Error: No default project set. Run: ${GCLOUD} config set project " - exit 1 -fi - -case "${MODE}" in - --follow) - echo "Following console output for ${INSTANCE_NAME} (Ctrl+C to stop)..." - echo "---" - - LAST_LINE=0 - while true; do - OUTPUT=$(${GCLOUD} compute instances get-serial-port-output "${INSTANCE_NAME}" \ - --zone="${ZONE}" \ - --project="${PROJECT}" \ - --start="${LAST_LINE}" 2>/dev/null || echo "") - - if [ -n "${OUTPUT}" ]; then - echo "${OUTPUT}" - # Count total lines to update start position - NEW_LINES=$(echo "${OUTPUT}" | wc -l) - LAST_LINE=$((LAST_LINE + NEW_LINES)) - fi - - sleep 2 - done - ;; - - --tail) - if [ $# -lt 4 ]; then - echo "Error: --tail requires a line count" - echo "Example: $0 ${INSTANCE_NAME} ${ZONE} --tail 50" - exit 1 - fi - - LINES="$4" - ${GCLOUD} compute instances get-serial-port-output "${INSTANCE_NAME}" \ - --zone="${ZONE}" \ - --project="${PROJECT}" | tail -n "${LINES}" - ;; - - *) - # Full output - ${GCLOUD} compute instances get-serial-port-output "${INSTANCE_NAME}" \ - --zone="${ZONE}" \ - --project="${PROJECT}" - ;; -esac diff --git a/tools/publish/gcp/install-gcloud.sh b/tools/publish/gcp/install-gcloud.sh deleted file mode 100755 index e0e1831..0000000 --- a/tools/publish/gcp/install-gcloud.sh +++ /dev/null @@ -1,170 +0,0 @@ -#!/bin/bash -# Script to download and install gcloud CLI locally in this directory -# Usage: ./install-gcloud.sh [--init|--no-init] [--force] -# --init Automatically run gcloud init after installation -# --no-init Skip gcloud init after installation -# --force Force reinstall if already installed -# (no flags: prompt user for both) - -set -euo pipefail - -# Parse flags -AUTO_INIT="" -FORCE_REINSTALL=false - -for arg in "$@"; do - case $arg in - --init) - AUTO_INIT="yes" - shift - ;; - --no-init) - AUTO_INIT="no" - shift - ;; - --force) - FORCE_REINSTALL=true - shift - ;; - *) - echo "Usage: $0 [--init|--no-init] [--force]" - echo " --init Automatically run gcloud init" - echo " --no-init Skip gcloud init" - echo " --force Force reinstall if already installed" - exit 1 - ;; - esac -done - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -INSTALL_DIR="${SCRIPT_DIR}/google-cloud-sdk" -GCLOUD_SYMLINK="${SCRIPT_DIR}/gcloud" - -echo "=== Google Cloud SDK Installer ===" -echo "Installing to: ${INSTALL_DIR}" -echo "" - -# Check if already installed locally -if [ -x "${INSTALL_DIR}/bin/gcloud" ]; then - CURRENT_VERSION=$(${INSTALL_DIR}/bin/gcloud version --format="value(version)" 2>/dev/null || echo "unknown") - echo "gcloud is already installed locally: ${CURRENT_VERSION}" - echo "" - - if [ "${FORCE_REINSTALL}" = true ]; then - echo "Force reinstall requested, removing existing installation..." - rm -rf "${INSTALL_DIR}" - else - read -p "Reinstall anyway? (y/N) " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Yy]$ ]]; then - exit 0 - fi - rm -rf "${INSTALL_DIR}" - fi -fi - -# Detect OS and architecture -OS=$(uname -s | tr '[:upper:]' '[:lower:]') -ARCH=$(uname -m) - -case "${OS}" in - linux) - case "${ARCH}" in - x86_64) - PACKAGE="google-cloud-cli-linux-x86_64.tar.gz" - ;; - aarch64|arm64) - PACKAGE="google-cloud-cli-linux-arm.tar.gz" - ;; - *) - echo "Error: Unsupported architecture: ${ARCH}" - exit 1 - ;; - esac - ;; - darwin) - case "${ARCH}" in - x86_64) - PACKAGE="google-cloud-cli-darwin-x86_64.tar.gz" - ;; - arm64) - PACKAGE="google-cloud-cli-darwin-arm.tar.gz" - ;; - *) - echo "Error: Unsupported architecture: ${ARCH}" - exit 1 - ;; - esac - ;; - *) - echo "Error: Unsupported OS: ${OS}" - echo "For Windows, download from: https://cloud.google.com/sdk/docs/install" - exit 1 - ;; -esac - -DOWNLOAD_URL="https://dl.google.com/dl/cloudsdk/channels/rapid/downloads/${PACKAGE}" -ARCHIVE_PATH="${SCRIPT_DIR}/${PACKAGE}" - -echo "Detected platform: ${OS} ${ARCH}" -echo "Package: ${PACKAGE}" -echo "" - -# Download archive if it doesn't exist -if [ -f "${ARCHIVE_PATH}" ]; then - echo "Using cached archive: ${ARCHIVE_PATH}" -else - echo "Downloading gcloud SDK..." - curl -L -o "${ARCHIVE_PATH}" "${DOWNLOAD_URL}" -fi - -echo "Extracting to ${SCRIPT_DIR}..." -tar -xzf "${ARCHIVE_PATH}" -C "${SCRIPT_DIR}" - -# Create symlink to gcloud binary -echo "Creating symlink: ${GCLOUD_SYMLINK} -> ${INSTALL_DIR}/bin/gcloud" -ln -sf "${INSTALL_DIR}/bin/gcloud" "${GCLOUD_SYMLINK}" - -echo "" -echo "=== Installation Complete ===" -echo "" -echo "gcloud installed to: ${INSTALL_DIR}/bin/gcloud" -echo "Symlink created: ./gcloud" -echo "" - -# Handle gcloud init based on flags or prompt -case "${AUTO_INIT}" in - yes) - echo "Running gcloud init..." - "${INSTALL_DIR}/bin/gcloud" init - ;; - no) - echo "Skipping gcloud init." - echo "" - echo "Initialize gcloud later with:" - echo " ./gcloud init" - echo "" - echo "Or set project manually:" - echo " ./gcloud config set project " - echo " ./gcloud auth login" - ;; - *) - # Prompt user - read -p "Initialize gcloud now? (authenticate and set project) (y/N) " -n 1 -r - echo - if [[ $REPLY =~ ^[Yy]$ ]]; then - "${INSTALL_DIR}/bin/gcloud" init - else - echo "" - echo "Initialize gcloud later with:" - echo " ./gcloud init" - echo "" - echo "Or set project manually:" - echo " ./gcloud config set project " - echo " ./gcloud auth login" - fi - ;; -esac - -echo "" -echo "The scripts in this directory will automatically use this local gcloud installation." diff --git a/tools/publish/gcp/launch-instance.sh b/tools/publish/gcp/launch-instance.sh deleted file mode 100755 index 4bc6b63..0000000 --- a/tools/publish/gcp/launch-instance.sh +++ /dev/null @@ -1,190 +0,0 @@ -#!/bin/bash -# Script to launch a Confidential VM instance with all required security settings -# Usage: ./launch-instance.sh [additional-gcloud-options...] -# Uses default gcloud project from active configuration - -set -euo pipefail - -# Find gcloud: check local installation first, then system -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -if [ -x "${SCRIPT_DIR}/gcloud" ]; then - GCLOUD="${SCRIPT_DIR}/gcloud" -elif [ -x "${SCRIPT_DIR}/google-cloud-sdk/bin/gcloud" ]; then - GCLOUD="${SCRIPT_DIR}/google-cloud-sdk/bin/gcloud" -elif command -v gcloud &> /dev/null; then - GCLOUD="gcloud" -else - echo "Error: gcloud not found. Install it with:" - echo " ./install-gcloud.sh" - exit 1 -fi - -if [ $# -lt 5 ]; then - echo "Usage: $0 [additional-options...]" - echo "" - echo "Examples:" - echo " # Launch x86_64 instance" - echo " $0 my-vm us-central1-a n2d-standard-2 lockboot-x86_64 config.json" - echo "" - echo " # Launch aarch64 instance with extra network settings" - echo " $0 my-vm us-central1-a t2a-standard-1 lockboot-aarch64 config.json --network=my-vpc" - echo "" - echo "Recommended machine types:" - echo " x86_64 SEV-SNP: n2d-standard-2, n2d-standard-4, c2d-standard-*" - echo " x86_64 TDX: c3-standard-*" - echo " aarch64: t2a-standard-1, t2a-standard-2, t2a-standard-4" - echo "" - echo "Common zones: us-central1-a, us-east1-b, europe-west1-b" - echo "" - echo "REQUIRED: user-data-file must contain lockboot configuration (JSON)" - echo "" - echo "Documentation:" - echo " Confidential VMs: https://cloud.google.com/confidential-computing/confidential-vm/docs" - echo " Machine types: https://cloud.google.com/compute/docs/machine-resource" - echo " Zones/Regions: https://cloud.google.com/compute/docs/regions-zones" - echo " Shielded VMs: https://cloud.google.com/security/shielded-cloud/shielded-vm" - echo " Metadata: https://cloud.google.com/compute/docs/metadata/setting-custom-metadata" - exit 1 -fi - -INSTANCE_NAME="$1" -ZONE="$2" -MACHINE_TYPE="$3" -IMAGE="$4" -USER_DATA_FILE="$5" -shift 5 # Remove first 5 args, remaining are passed through - -# Validate user-data file exists -if [ ! -f "${USER_DATA_FILE}" ]; then - echo "Error: User data file '${USER_DATA_FILE}' not found" - echo "Lockboot requires a configuration file for stage2 download and verification" - exit 1 -fi - -# Get current project from gcloud config -PROJECT=$(${GCLOUD} config get-value project 2>/dev/null || echo "") -if [ -z "${PROJECT}" ]; then - echo "Error: No default project set. Run: ${GCLOUD} config set project " - exit 1 -fi - -echo "=== Launching Confidential VM Instance ===" -echo "Instance: ${INSTANCE_NAME}" -echo "Project: ${PROJECT}" -echo "Zone: ${ZONE}" -echo "Machine: ${MACHINE_TYPE}" -echo "Image: ${IMAGE}" -echo "User-data: ${USER_DATA_FILE}" -echo "" - -# Validate machine type supports Confidential Compute -if [[ "${MACHINE_TYPE}" =~ ^n2d- ]] || [[ "${MACHINE_TYPE}" =~ ^c2d- ]]; then - TECH="AMD SEV-SNP" -elif [[ "${MACHINE_TYPE}" =~ ^c3- ]]; then - TECH="Intel TDX" -elif [[ "${MACHINE_TYPE}" =~ ^t2a- ]]; then - TECH="ARM TrustZone" -else - echo "Warning: Machine type '${MACHINE_TYPE}' may not support Confidential Computing" - echo "Recommended: n2d-*, c2d-*, c3-*, or t2a-*" - read -p "Continue anyway? (y/N) " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Yy]$ ]]; then - exit 1 - fi - TECH="Unknown" -fi - -echo "Confidential Compute: ${TECH}" -echo "" - -# Check if instance already exists -EXISTING=$(${GCLOUD} compute instances list \ - --project="${PROJECT}" \ - --filter="name=${INSTANCE_NAME} AND zone:${ZONE}" \ - --format="value(name)" \ - 2>/dev/null || echo "") - -if [ -n "${EXISTING}" ]; then - echo "Error: Instance '${INSTANCE_NAME}' already exists in zone ${ZONE}" - exit 1 -fi - -echo "Creating instance with REQUIRED Confidential VM settings:" -echo " ✓ --confidential-compute-type (enables memory encryption)" -echo " ✓ --maintenance-policy=TERMINATE (prevents live migration)" -echo " ✓ --shielded-secure-boot (UEFI Secure Boot)" -echo " ✓ --shielded-vtpm (virtual TPM 2.0)" -echo " ✓ --shielded-integrity-monitoring" -echo " ✓ --boot-disk-type=pd-standard (cheapest, loaded once into memory)" -echo " ✓ --boot-disk-auto-delete (cleanup on instance delete)" -echo "" - -# Determine confidential compute type based on machine type -if [[ "${MACHINE_TYPE}" =~ ^c3- ]]; then - CONF_COMPUTE_TYPE="TDX" -elif [[ "${MACHINE_TYPE}" =~ ^(n2d-|c2d-) ]]; then - CONF_COMPUTE_TYPE="SEV" -elif [[ "${MACHINE_TYPE}" =~ ^t2a- ]]; then - # ARM doesn't need explicit type - CONF_COMPUTE_TYPE="" -else - CONF_COMPUTE_TYPE="SEV" # Default to SEV -fi - -# Launch instance with all required Confidential VM settings -if [ -n "${CONF_COMPUTE_TYPE}" ]; then - ${GCLOUD} compute instances create "${INSTANCE_NAME}" \ - --project="${PROJECT}" \ - --zone="${ZONE}" \ - --machine-type="${MACHINE_TYPE}" \ - --image="${IMAGE}" \ - --network-interface=nic-type=GVNIC \ - --boot-disk-type=pd-standard \ - --boot-disk-auto-delete \ - --metadata-from-file=user-data="${USER_DATA_FILE}" \ - --metadata=serial-port-enable=true \ - --confidential-compute-type="${CONF_COMPUTE_TYPE}" \ - --maintenance-policy=TERMINATE \ - --shielded-secure-boot \ - --shielded-vtpm \ - --shielded-integrity-monitoring \ - "$@" -else - # ARM: use old flag for now (TODO: check if ARM needs specific type) - ${GCLOUD} compute instances create "${INSTANCE_NAME}" \ - --project="${PROJECT}" \ - --zone="${ZONE}" \ - --machine-type="${MACHINE_TYPE}" \ - --image="${IMAGE}" \ - --network-interface=nic-type=GVNIC \ - --boot-disk-type=pd-standard \ - --boot-disk-auto-delete \ - --metadata-from-file=user-data="${USER_DATA_FILE}" \ - --metadata=serial-port-enable=true \ - --confidential-compute \ - --maintenance-policy=TERMINATE \ - --shielded-secure-boot \ - --shielded-vtpm \ - --shielded-integrity-monitoring \ - "$@" -fi - -echo "" -echo "=== Instance Created Successfully ===" -echo "" -echo "Connect to instance:" -echo " ${GCLOUD} compute ssh ${INSTANCE_NAME} --zone=${ZONE}" -echo "" -echo "View serial console:" -echo " ${GCLOUD} compute instances get-serial-port-output ${INSTANCE_NAME} --zone=${ZONE}" -echo " Or use: ./get-console.sh ${INSTANCE_NAME} ${ZONE}" -echo "" -echo "View instance details:" -echo " ${GCLOUD} compute instances describe ${INSTANCE_NAME} --zone=${ZONE}" -echo "" -echo "Launch with spot/preemptible pricing (up to 90% cheaper):" -echo " Add --provisioning-model=SPOT --instance-termination-action=DELETE to the create command" -echo "" -echo "IMPORTANT: This instance will TERMINATE (not migrate) during maintenance events." -echo "This is required to maintain the Confidential Computing trust model." diff --git a/tools/qemu-test/.gitignore b/tools/qemu-test/.gitignore deleted file mode 100644 index ee48116..0000000 --- a/tools/qemu-test/.gitignore +++ /dev/null @@ -1,11 +0,0 @@ -busybox -bubblewrap -output -ec2-metadata-mock-linux-amd64 -stage1 -secure-boot-keys -*.rpm -x86_64 -aarch64 -*.apk -downloads \ No newline at end of file diff --git a/tools/qemu-test/Makefile b/tools/qemu-test/Makefile deleted file mode 100644 index 7ac89b6..0000000 --- a/tools/qemu-test/Makefile +++ /dev/null @@ -1,19 +0,0 @@ -.PHONY: all clean - -# Amazon EC2 Metadata Mock -AEMM_VERSION := v1.13.0 -AEMM_URL := https://github.com/aws/amazon-ec2-metadata-mock/releases/download/$(AEMM_VERSION)/ec2-metadata-mock-linux-amd64 -AEMM_SHA256 := 4f89ddc71ac53ce540bda1f9c340526d558eed8e41349761f2798acf1b254950 - -all: ec2-metadata-mock-linux-amd64 - -ec2-metadata-mock-linux-amd64: - @echo "Downloading EC2 metadata mock..." - @curl -fsSL -o "$@.tmp" "$(AEMM_URL)" - @echo "$(AEMM_SHA256) $@.tmp" | sha256sum -c - || (rm -f "$@.tmp"; exit 1) - @mv "$@.tmp" "$@" - @chmod +x "$@" - @echo "✓ Downloaded and verified: $@" - -clean: - rm -f ec2-metadata-mock-linux-amd64 diff --git a/tools/qemu-test/boot.sh b/tools/qemu-test/boot.sh deleted file mode 100755 index d2b7cc6..0000000 --- a/tools/qemu-test/boot.sh +++ /dev/null @@ -1,315 +0,0 @@ -#!/bin/bash -set -euox pipefail - -# Get the absolute path of the script directory -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -# Repository root (computed from script location: tools/qemu-test -> ../..) -REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" - -usage() { - cat <<'EOF' -Usage: boot.sh [OPTIONS] - -Boot a lockboot disk image under QEMU with Secure Boot, an emulated TPM 2.0, -and a mocked EC2 metadata service. - -Options: - --kind What to boot (default: uki). - uki = Linux Unified Kernel Image (stage1 path) - stage0 = pure-UEFI network bootloader - --arch Target architecture (default: $ARCH or x86_64). - --boot-disk Override the disk image to boot. - --user-data Override the metadata user-data JSON. - --ovmf-vars Override the OVMF/EFI variables file. - --payload (stage0) Serve this UEFI payload over HTTP at - http://10.0.2.1:8000/payload.efi for stage0 to fetch. - --serve-dir (stage0) Serve this whole directory at - http://10.0.2.1:8000/ instead of a single payload. Used - by the full chain (UKI at /linux.efi, stage2 at /stage2). - --trace Capture the guest's TCP traffic on tap0 to a pcap at - stage0-trace.pcap in the repo root (bind-mounted, so it - persists on the host). Open in Wireshark / tshark to - reassemble the HTTP streams. - -h, --help Show this help and exit. - -Defaults by --kind: - uki : disk tools/build-uki//boot.disk, user-data.json - stage0 : disk tools/build-stage0//boot.disk, user-data.stage0.json - -The tap/iptables setup needs NET_ADMIN; run via 'make boot-...' (privileged -dev container) or with sudo and YES_INSIDE_DOCKER_DO_DANGEROUS_IPTABLES=1. -EOF -} - -# Defaults (ARCH may still come from the environment for Makefile compatibility) -ARCH="${ARCH:-x86_64}" -BOOT_KIND="uki" -BOOT_DISK="" -USER_DATA="" -OVMF_VARS_OVERRIDE="" -PAYLOAD="" -SERVE_DIR="" -TRACE=0 - -while [ $# -gt 0 ]; do - case "$1" in - --kind) BOOT_KIND="$2"; shift 2 ;; - --arch) ARCH="$2"; shift 2 ;; - --boot-disk) BOOT_DISK="$2"; shift 2 ;; - --user-data) USER_DATA="$2"; shift 2 ;; - --ovmf-vars) OVMF_VARS_OVERRIDE="$2"; shift 2 ;; - --payload) PAYLOAD="$2"; shift 2 ;; - --serve-dir) SERVE_DIR="$2"; shift 2 ;; - --trace) TRACE=1; shift ;; - -h|--help) usage; exit 0 ;; - *) echo "Unknown option: $1" >&2; usage; exit 1 ;; - esac -done - -if [ "${YES_INSIDE_DOCKER_DO_DANGEROUS_IPTABLES:-}" != 1 ]; then - echo "Error: not inside docker, refusing to do dangerous stuff!!" - echo "Run via 'make boot-...' or sudo with YES_INSIDE_DOCKER_DO_DANGEROUS_IPTABLES=1." - exit 1 -fi - -case "${ARCH}" in x86_64|aarch64) ;; *) echo "Unsupported ARCH: ${ARCH}"; exit 1 ;; esac -case "${BOOT_KIND}" in uki|stage0) ;; *) echo "Unknown --kind: ${BOOT_KIND}"; exit 1 ;; esac - -KEYDIR="${REPO_ROOT}/tools/build-uki/keys" -TMP=/tmp - -# Resolve disk / user-data defaults from the selected kind (flags win). -if [ "${BOOT_KIND}" = "stage0" ]; then - DISK_DIR="${REPO_ROOT}/tools/build-stage0/${ARCH}" - : "${USER_DATA:=${REPO_ROOT}/user-data.stage0.json}" -else - DISK_DIR="${REPO_ROOT}/tools/build-uki/${ARCH}" - : "${USER_DATA:=${REPO_ROOT}/user-data.json}" -fi -: "${BOOT_DISK:=${DISK_DIR}/boot.disk}" - -echo "=== Booting ${BOOT_KIND} with Secure Boot + TPM 2.0 (${ARCH}) ===" -echo "User-data file: ${USER_DATA}" -echo "Boot disk: ${BOOT_DISK}" - -AMMM=${SCRIPT_DIR}/ec2-metadata-mock-linux-amd64 - -# Check dependencies -if [ ! -f ${AMMM} ]; then - echo "Error: ${AMMM} not found. Run 'make ec2-metadata-mock-linux-amd64' first." - exit 1 -fi - -if [ ! -f "${USER_DATA}" ]; then - echo "Error: User-data file '${USER_DATA}' not found." - exit 1 -fi - -# Boot disk resolved above from --kind/--boot-disk. -if [ ! -f "${BOOT_DISK}" ]; then - echo "Error: ${BOOT_DISK} not found. Build it first (see --help)." - exit 1 -fi - -# OVMF firmware paths (architecture-specific) -if [ "${ARCH}" = "x86_64" ]; then - OVMF_CODE="/usr/share/OVMF/OVMF_CODE_4M.secboot.fd" - QEMU_CMD="qemu-system-x86_64" - QEMU_MACHINE="-machine q35,smm=on" - QEMU_CPU="" - QEMU_EXTRA="-enable-kvm" - # ISA serial at 0x3f8 = ttyS0, matches how GRUB/Fedora expects serial on x86_64 - QEMU_SERIAL="-serial none" - QEMU_SERIAL_DEVICE="-device isa-serial,chardev=char0" - TPM_DEVICE="tpm-tis" -elif [ "${ARCH}" = "aarch64" ]; then - OVMF_CODE="/usr/share/AAVMF/AAVMF_CODE.fd" - QEMU_CMD="qemu-system-aarch64" - # GIC version 3 is more modern, try gic-version=2 if it doesn't work - QEMU_MACHINE="-machine virt" - #QEMU_MACHINE="-machine virt,gic-version=2" - #QEMU_MACHINE="-machine sbsa-ref" - # Try different CPU models if one doesn't work: - # -cpu cortex-a57 (older, well-supported) - # -cpu cortex-a72 (similar to a57) - # -cpu max (all features, but may cause issues) - QEMU_CPU="-cpu cortex-a72" - #QEMU_CPU="" - QEMU_EXTRA="" - # -serial none: PL011 exists but with no backend, PCI serial is ttyS0 - QEMU_SERIAL="-serial none" - QEMU_SERIAL_DEVICE="-device pci-serial,id=serial0,chardev=char0" - TPM_DEVICE="tpm-tis-device" -else - echo "Error: Unsupported architecture: ${ARCH}" - exit 1 -fi - -OVMF_VARS_ORIG="${OVMF_VARS_OVERRIDE:-${DISK_DIR}/efi-vars.ovmf}" -OVMF_VARS="/tmp/efi-vars.ovmf" - -if [ ! -f "${OVMF_CODE}" ]; then - echo "Error: ${OVMF_CODE} not found. Install ovmf package." - exit 1 -fi - -cp "${OVMF_VARS_ORIG}" "${OVMF_VARS}" - -# Setup TPM state directory -mkdir -p $TMP/tpm-state - -# Provision NV indices for GCP-style attestation (idempotent) -# This starts its own swtpm instance with tpm2-tools-compatible sockets, then shuts it down -${SCRIPT_DIR}/provision-test-tpm.sh $TMP/tpm-state - -# Start swtpm for QEMU (original way - just ctrl socket) -swtpm socket --tpmstate dir=$TMP/tpm-state \ - --ctrl type=unixio,path=$TMP/swtpm-sock \ - --tpm2 \ - --pid file=$TMP/swtpm.pid \ - --daemon -sleep 1 - -# Cleanup function -cleanup() { - kill $(cat $TMP/swtpm.pid 2>/dev/null) 2>/dev/null || true - kill $(cat $TMP/ec2-mock.pid 2>/dev/null) 2>/dev/null || true - kill $(cat $TMP/payload-http.pid 2>/dev/null) 2>/dev/null || true - kill $(cat $TMP/tcpdump.pid 2>/dev/null) 2>/dev/null || true - # The boot runs as root; hand the trace back to the host user. - if [ "${TRACE}" = 1 ] && [ -n "${OWNER_UID:-}" ] && [ -f "${TRACE_FILE:-}" ]; then - chown "${OWNER_UID}:${OWNER_GID:-${OWNER_UID}}" "${TRACE_FILE}" 2>/dev/null || true - fi -} - -# Set trap to cleanup on exit -trap cleanup EXIT INT TERM - -# Boot the UKI -echo "Press Ctrl-A, then X to exit QEMU" - -#-netdev "user,id=net0,net=169.254.169.0/24,guestfwd=tcp:169.254.169.254:80-cmd:/usr/bin/nc 192.168.3.5 1338" \ - -ip tuntap add dev tap0 mode tap -ip link set tap0 up -ip addr add 10.0.2.1/24 dev tap0 -ip addr add 169.254.169.254/24 dev tap0 - -# Optional: capture the guest's full TCP traffic to a pcap (full packets, so the -# HTTP streams can be reassembled/extracted in Wireshark/tshark). Written into -# the bind-mounted repo so it survives the container. -TRACE_FILE="${REPO_ROOT}/stage0-trace.pcap" -if [ "${TRACE}" = 1 ]; then - echo "Capturing tap0 TCP -> ${TRACE_FILE} (repo root; open in Wireshark)" - tcpdump -i tap0 -s 0 -U -w "${TRACE_FILE}" tcp 2>/dev/null & - echo $! > $TMP/tcpdump.pid - sleep 0.5 -fi - -# Create AEMM config with user-data -echo "Starting EC2 metadata mock..." -echo '{"userdata":{"values":{"userdata":"'$(base64 -w0 "${USER_DATA}")'"}}}' > $TMP/aemm-config.json - -# Start EC2 metadata mock -${AMMM} \ - --imdsv2 \ - -n 169.254.169.254 \ - --port 80 \ - --config-file $TMP/aemm-config.json & -echo $! > $TMP/ec2-mock.pid - -# Give services time to start -sleep 1 - -# Serve a local tree over HTTP on the tap gateway, so `_stage1`/`_stage2` user-data -# can point at http://10.0.2.1:8000/. --serve-dir serves a prepared directory -# (full chain: /linux.efi for stage0, /stage2 for stage1); --payload wraps a single -# file as /payload.efi (stage0 isolation). -SERVE_ROOT="" -if [ -n "${SERVE_DIR}" ]; then - [ -d "${SERVE_DIR}" ] || { echo "Error: serve-dir ${SERVE_DIR} not found"; exit 1; } - SERVE_ROOT="${SERVE_DIR}" -elif [ -n "${PAYLOAD}" ]; then - [ -f "${PAYLOAD}" ] || { echo "Error: payload ${PAYLOAD} not found"; exit 1; } - SERVE_ROOT=$(mktemp -d) - cp "${PAYLOAD}" "${SERVE_ROOT}/payload.efi" - # In signed mode stage0 also fetches a detached signature at .sig. - [ -f "${PAYLOAD}.sig" ] && cp "${PAYLOAD}.sig" "${SERVE_ROOT}/payload.efi.sig" -fi -if [ -n "${SERVE_ROOT}" ]; then - # Serve HTTP/1.1 (Content-Length + keep-alive). The stdlib `http.server` default - # is HTTP/1.0 `Connection: close`, which OVMF's HttpDxe never completes the - # response token for. Real object stores (S3/GCS) serve HTTP/1.1. - ( cd "${SERVE_ROOT}" && exec python3 -c 'import http.server; http.server.SimpleHTTPRequestHandler.protocol_version="HTTP/1.1"; http.server.ThreadingHTTPServer(("10.0.2.1",8000), http.server.SimpleHTTPRequestHandler).serve_forever()' ) & - echo $! > $TMP/payload-http.pid - echo "Serving ${SERVE_ROOT} (HTTP/1.1) at http://10.0.2.1:8000/ :" - ls -l "${SERVE_ROOT}" | sed 's/^/ /' -fi - -echo 1 > /proc/sys/net/ipv4/ip_forward -iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE - -# Create dnsmasq hosts file with EC2-style hostnames -echo "Generating /tmp/dnsmasq-hosts..." -cat > /tmp/dnsmasq-hosts </dev/null; then - echo "TPM already provisioned, skipping" - exit 0 -fi - -echo "Provisioning TPM for GCP-style attestation..." - -# 1. Create hardcoded TPMT_PUBLIC template (empty unique) -# Based on vaportpm's template but WITHOUT restricted attribute for tpm2-tools compatibility: -# type=ECC(0x0023), nameAlg=SHA256(0x000b), attrs=0x00040072 (no restricted) -# symmetric=Null, scheme=ECDSA-SHA256, curve=P256, kdf=Null -# unique x_size=0, y_size=0 -# Note: tpm2_createprimary can't create restricted ECC signing keys (symmetric bug) -echo -n "0023000b00040072000000100018000b0003001000000000" | xxd -r -p > template.bin - -# 2. Write template to NV FIRST (before creating key) -tpm2_nvdefine $NV_ECC_TEMPLATE -s 24 -a "ownerread|ownerwrite|authread|authwrite" -tpm2_nvwrite $NV_ECC_TEMPLATE -i template.bin - -# 3. Create key (non-restricted to work around tpm2-tools symmetric bug) -tpm2_createprimary -C e -G ecc:ecdsa-sha256 \ - -a 'fixedtpm|fixedparent|sensitivedataorigin|userwithauth|sign' \ - -c ak.ctx - -# 4. Extract public key as PEM for certificate -tpm2_readpublic -c ak.ctx -f pem -o ak.pub.pem - -# 5. Create test CA -openssl ecparam -genkey -name prime256v1 -noout -out ca.key -openssl req -new -x509 -key ca.key -out ca.crt -days 3650 \ - -subj "/CN=LockBoot Test CA" -batch - -# 6. Create AK cert with TPM's public key (using -force_pubkey) -openssl req -new -key ca.key -subj "/CN=Test AK" -out ak.csr -batch -openssl x509 -req -in ak.csr -CA ca.crt -CAkey ca.key \ - -force_pubkey ak.pub.pem -out ak.crt -days 3650 \ - -extfile <(echo "keyUsage=critical,digitalSignature") \ - -CAcreateserial - -# 7. Convert cert to DER and write to NV -openssl x509 -in ak.crt -outform DER -out ak.crt.der -tpm2_nvdefine $NV_ECC_CERT -s $(stat -c%s ak.crt.der) -a "ownerread|ownerwrite|authread|authwrite" -tpm2_nvwrite $NV_ECC_CERT -i ak.crt.der - -# 8. Cleanup TPM transient objects -tpm2_flushcontext -t - -# 9. Clean shutdown to save state (boot.sh will restart swtpm) -tpm2_shutdown -swtpm_ioctl --unix ${SOCKET}.ctrl -s - -echo "TPM provisioned successfully for GCP-style attestation"