From 0d524a2752972d5d8b1b4a7dba8a034fb1e67556 Mon Sep 17 00:00:00 2001 From: HarryR <303926+HarryR@users.noreply.github.com> Date: Sun, 21 Jun 2026 20:03:11 +0000 Subject: [PATCH] stage0 netboot rework, mkuki library, and local full-chain test harness One unit of work across stage0, mkuki, the metadata schema, and the test/CI tooling; the pieces interlock and were not cleanly separable. stage0 transport: - Replace EFI_HTTP/HttpDxe with a raw EFI_TCP4 + EFI_DNS4 client (tcp4.rs, dns4.rs, http.rs, net.rs). HttpDxe does not drain multi-segment response bodies and is the least portable network driver; TCP4/DNS4 is the portable subset. A tight-poll pump() fixes receive throughput. DNS configures statically from the existing DHCP lease (no second DHCP). - Boot-relative timing logs (timing.rs) for diagnosing stalls. stage0 admission: - Embedded _stage1 metadata via a PE section (embedded.rs), used in place of the cloud metadata service when present (signed and measured with the image). - Signed remote args (args_url plus optional args_sig_url, {sha256} templated). - Content-addressable sig_url ({sha256}); reject non-http URLs. Metadata schema: - Rename the _stage0 key to _stage1. The key names the target a stage loads: stage0 reads _stage1, stage1 reads _stage2. mkuki: - Rename crate lockboot-mkuki to mkuki and expose it as an importable library (lib.rs) with a thin CLI; tracing-based logging. Test harness: - make boot-stage0- now defaults to the signed test payload and regenerates user-data each run, so it works with no arguments and never goes stale. test-stage0 is an alias. - New make test-chain-: full stage0 -> UKI -> stage1 -> stage2 chain served from one local directory (no S3). boot.sh gains --serve-dir. Makefile / CI: - Dependency prep and key generation run inside the build container, so slim runners (e.g. act) without rpm2cpio/cpio/curl can build. - Remove the legacy direct-UKI boot targets (UKI is netboot-only via stage0). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/build.yml | 198 ++++++---- Cargo.lock | 635 +++++++++++++++++++++++++++++- Cargo.toml | 2 +- Dockerfile.runtime | 4 +- Makefile | 169 +++++--- README.md | 2 +- crates/mkuki/Cargo.toml | 32 ++ crates/mkuki/README.md | 88 +++++ crates/mkuki/src/cpio.rs | 267 +++++++++++++ crates/mkuki/src/lib.rs | 217 ++++++++++ crates/mkuki/src/main.rs | 200 ++++++++++ crates/mkuki/src/sign.rs | 69 ++++ crates/mkuki/src/uki.rs | 172 ++++++++ crates/stage0/Cargo.toml | 6 + crates/stage0/README.md | 207 +++++----- crates/stage0/src/config.rs | 76 +++- crates/stage0/src/dns4.rs | 123 ++++-- crates/stage0/src/embedded.rs | 86 ++++ crates/stage0/src/http.rs | 514 ++++++------------------ crates/stage0/src/main.rs | 166 +++++--- crates/stage0/src/metadata.rs | 48 +-- crates/stage0/src/net.rs | 90 +++++ crates/stage0/src/secauth.rs | 14 +- crates/stage0/src/sig.rs | 2 +- crates/stage0/src/tcg2.rs | 2 +- crates/stage0/src/tcp4.rs | 138 ++----- crates/stage0/src/timing.rs | 117 ++++++ tools/build-stage0/build.sh | 20 + tools/build-uki/.gitignore | 2 + tools/build-uki/build.sh | 318 ++++----------- tools/publish/ec2/create-ami.sh | 14 +- tools/publish/gcp/create-image.sh | 14 +- tools/publish/upload-uki.sh | 94 +++++ tools/qemu-test/boot.sh | 44 ++- 34 files changed, 2993 insertions(+), 1157 deletions(-) create mode 100644 crates/mkuki/Cargo.toml create mode 100644 crates/mkuki/README.md create mode 100644 crates/mkuki/src/cpio.rs create mode 100644 crates/mkuki/src/lib.rs create mode 100644 crates/mkuki/src/main.rs create mode 100644 crates/mkuki/src/sign.rs create mode 100644 crates/mkuki/src/uki.rs create mode 100644 crates/stage0/src/embedded.rs create mode 100644 crates/stage0/src/net.rs create mode 100644 crates/stage0/src/timing.rs create mode 100755 tools/publish/upload-uki.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fff0d93..350e7f1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -3,7 +3,10 @@ name: build on: push: branches: [main] - tags: ['v*'] + # 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*'] pull_request: branches: [main] workflow_dispatch: @@ -19,33 +22,130 @@ jobs: - name: Checkout repository uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - - name: Build UKI for ${{ matrix.arch }} - run: make ${{ matrix.arch }} - - - name: Copy public keys to arch directory - run: | - cp tools/build-uki/keys/*.cer tools/build-uki/${{ matrix.arch }}/ - cp tools/build-uki/keys/*.guid tools/build-uki/${{ matrix.arch }}/ + # 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: + # 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. - name: Upload UKI artifacts for ${{ matrix.arch }} uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: - name: ${{ matrix.arch }} + name: uki-${{ matrix.arch }} path: | - tools/build-uki/${{ matrix.arch }}/boot.disk + tools/build-uki/${{ matrix.arch }}/linux.efi + tools/build-uki/${{ matrix.arch }}/linux.efi.sha256 + tools/build-uki/${{ matrix.arch }}/stage0-snippet.json + tools/build-uki/${{ matrix.arch }}/os-release tools/build-uki/${{ matrix.arch }}/busybox tools/build-uki/${{ matrix.arch }}/stage1 - tools/build-uki/${{ matrix.arch }}/os-release - tools/build-uki/${{ matrix.arch }}/efi-vars.* - tools/build-uki/${{ matrix.arch }}/*.cer - tools/build-uki/${{ matrix.arch }}/*.guid - - release-docker: + + # 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@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + 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/v') + 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@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2 + 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 + + # ---- UKI (stage1) release track (tag: uki-v*) ---------------------------- + release-uki: + runs-on: ubuntu-latest + needs: build + if: startsWith(github.ref, 'refs/tags/uki-v') + permissions: + contents: write + id-token: write + attestations: write + + steps: + - name: Download UKI 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==\"uki-$arch\") | .id") + gh api repos/${{ github.repository }}/actions/artifacts/${ARTIFACT_ID}/zip > uki-${arch}.zip + done + + - name: Attest UKI zips + uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0 + with: + subject-path: | + uki-x86_64.zip + uki-aarch64.zip + + - name: Create UKI release + uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2 + with: + tag_name: ${{ github.ref_name }} + files: | + uki-x86_64.zip + uki-aarch64.zip + draft: false + prerelease: true + generate_release_notes: true + make_latest: true + + # ---- stage1 runtime container image (tag: uki-v*) ------------------------ + # Minimal busybox + stage1 utility image; belongs to the stage1/UKI track. + release-docker: + runs-on: ubuntu-latest + needs: build + if: startsWith(github.ref, 'refs/tags/uki-v') + permissions: + contents: read packages: write id-token: write attestations: write @@ -54,16 +154,16 @@ jobs: - name: Checkout repository uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - - name: Download x86_64 artifacts + - name: Download x86_64 UKI artifacts uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: - name: x86_64 + name: uki-x86_64 path: tools/build-uki/x86_64 - - name: Download aarch64 artifacts + - name: Download aarch64 UKI artifacts uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: - name: aarch64 + name: uki-aarch64 path: tools/build-uki/aarch64 - name: Set up Docker Buildx @@ -106,59 +206,3 @@ jobs: subject-name: ghcr.io/${{ github.repository }} subject-digest: ${{ steps.push.outputs.digest }} push-to-registry: true - - release-zip: - runs-on: ubuntu-latest - needs: build - # Only run if build succeeded and it was triggered by a tag - if: startsWith(github.ref, 'refs/tags/v') - permissions: - contents: write - id-token: write - attestations: write - - steps: - - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - - - name: Download artifact zips directly via API - 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==\"$arch\") | .id") - gh api repos/${{ github.repository }}/actions/artifacts/${ARTIFACT_ID}/zip > ${arch}.zip - done - - - name: Attest x86_64 zip - uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0 - with: - subject-path: 'x86_64.zip' - - - name: Attest aarch64 zip - uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0 - with: - subject-path: 'aarch64.zip' - - - name: Extract metadata for Docker - id: meta - uses: docker/metadata-action@8e5442c4ef9f78752691e2d8f8d19755c6f78e81 # v5.5.1 - with: - images: ghcr.io/${{ github.repository }} - tags: | - type=ref,event=tag - flavor: | - latest=auto - - - name: Create Release - uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2 - with: - tag_name: ${{ steps.meta.outputs.version }} - files: | - x86_64.zip - aarch64.zip - draft: false - prerelease: true - generate_release_notes: true - make_latest: true diff --git a/Cargo.lock b/Cargo.lock index 6bb6099..7cd023b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aead" version = "0.5.2" @@ -37,6 +43,65 @@ dependencies = [ "subtle", ] +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.101" @@ -178,6 +243,52 @@ dependencies = [ "zeroize", ] +[[package]] +name = "clap" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "const-oid" version = "0.9.6" @@ -193,6 +304,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crunchy" version = "0.2.4" @@ -221,6 +341,12 @@ dependencies = [ "typenum", ] +[[package]] +name = "ct-codecs" +version = "1.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49fb0c6640b4507ebd99ff67677009e381ba5eee1d14df78de4a3d16eb123c39" + [[package]] name = "ctr" version = "0.9.2" @@ -328,6 +454,16 @@ dependencies = [ "signature", ] +[[package]] +name = "ed25519-compact" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5c0284a5d4b1a2fae017a9fe55fd7d01699711f1b572493f16593e173ea2801" +dependencies = [ + "ct-codecs", + "getrandom 0.4.2", +] + [[package]] name = "ed25519-dalek" version = "2.2.0" @@ -363,6 +499,22 @@ dependencies = [ "zeroize", ] +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "example-stage2" version = "0.1.0" @@ -386,6 +538,16 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -398,6 +560,22 @@ version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -479,6 +657,21 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasip3", + "wasm-bindgen", +] + [[package]] name = "ghash" version = "0.5.1" @@ -511,6 +704,27 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hex" version = "0.4.3" @@ -716,6 +930,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "idna" version = "1.1.0" @@ -737,6 +957,18 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + [[package]] name = "inout" version = "0.1.4" @@ -762,6 +994,12 @@ dependencies = [ "serde", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.17" @@ -787,6 +1025,12 @@ dependencies = [ "spin", ] +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.180" @@ -799,6 +1043,12 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + [[package]] name = "litemap" version = "0.8.1" @@ -811,12 +1061,31 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.1.1" @@ -828,6 +1097,31 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "mkuki" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64", + "clap", + "ed25519-compact", + "flate2", + "sha2", + "tar", + "tracing", + "tracing-subscriber", + "walkdir", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-bigint-dig" version = "0.8.6" @@ -880,6 +1174,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "opaque-debug" version = "0.3.1" @@ -1016,6 +1316,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "primeorder" version = "0.13.6" @@ -1043,6 +1353,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.5" @@ -1069,9 +1385,26 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.17", ] +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "reqwest" version = "0.12.24" @@ -1129,7 +1462,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", @@ -1165,6 +1498,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.23" @@ -1240,6 +1586,15 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "sec1" version = "0.7.3" @@ -1347,6 +1702,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1363,6 +1727,12 @@ dependencies = [ "rand_core", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "slab" version = "0.4.12" @@ -1424,6 +1794,12 @@ dependencies = [ "vaportpm-attest", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" @@ -1461,6 +1837,17 @@ dependencies = [ "syn", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -1481,6 +1868,15 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -1588,9 +1984,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -1598,6 +2006,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -1618,6 +2056,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "universal-hash" version = "0.5.1" @@ -1652,6 +2096,18 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vaportpm-attest" version = "0.1.0" @@ -1678,6 +2134,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -1693,6 +2159,24 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + [[package]] name = "wasm-bindgen" version = "0.2.108" @@ -1752,6 +2236,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "web-sys" version = "0.3.85" @@ -1771,6 +2289,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -1933,6 +2460,100 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "writeable" version = "0.6.2" @@ -1962,6 +2583,16 @@ dependencies = [ "tls_codec", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.1" diff --git a/Cargo.toml b/Cargo.toml index d41e010..eb498b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/stage1", "crates/example-stage2"] +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 diff --git a/Dockerfile.runtime b/Dockerfile.runtime index 16a923c..68840a8 100644 --- a/Dockerfile.runtime +++ b/Dockerfile.runtime @@ -11,9 +11,9 @@ COPY tools/build-uki/aarch64/ /binaries/aarch64/ # Create arch-specific directory based on TARGETARCH RUN mkdir -p /target && \ if [ "$TARGETARCH" = "amd64" ]; then \ - cp /binaries/x86_64/busybox /binaries/x86_64/bubblewrap /binaries/x86_64/stage1 /target/; \ + cp /binaries/x86_64/busybox /binaries/x86_64/stage1 /target/; \ elif [ "$TARGETARCH" = "arm64" ]; then \ - cp /binaries/aarch64/busybox /binaries/aarch64/bubblewrap /binaries/aarch64/stage1 /target/; \ + cp /binaries/aarch64/busybox /binaries/aarch64/stage1 /target/; \ else \ echo "Unsupported architecture: $TARGETARCH" && exit 1; \ fi diff --git a/Makefile b/Makefile index ee2d0f0..7ef0367 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,13 @@ .PRECIOUS: tools/build-uki/keys/% tools/build-uki/% \ - tools/build-stage0/%/stage0.efi tools/build-stage0/%/payload.efi tools/build-stage0/%/boot.disk + tools/build-stage0/%/stage0.efi tools/build-stage0/%/payload.efi tools/build-stage0/%/stage2 tools/build-stage0/%/boot.disk all: build ARCHS=x86_64 aarch64 build: $(ARCHS) -amd64 x86_64: tools/build-uki/x86_64/boot.disk -arm64 aarch64: tools/build-uki/aarch64/boot.disk +amd64 x86_64: tools/build-uki/x86_64/linux.efi +arm64 aarch64: tools/build-uki/aarch64/linux.efi DEFAULT_STAGE2_URL = https://lockboot.s3.us-east-1.amazonaws.com/examples/stage2/user-data.json user-data.json: @@ -18,31 +18,36 @@ BUILD_IMAGE = lockboot:build DEV_IMAGE = lockboot:dev RUNTIME_IMAGE ?= lockboot:latest -tools/build-uki/keys/%: - $(MAKE) -C tools/build-uki/keys +# 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 dependencies via tools/build-uki Makefile -tools/build-uki/%/busybox: - $(MAKE) -C tools/build-uki $*/busybox +# 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: - $(MAKE) -C tools/build-uki $*/stub.efi +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: - $(MAKE) -C tools/build-uki $*/kernel-core.rpm +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: - $(MAKE) -C tools/build-uki $*/kernel-modules-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 $* @@ -133,22 +138,29 @@ docker-shell-base: docker-build-base docker-shell-dev: docker-build-dev $(DOCKER_RUN) -ti $(DOCKER_SAMEUSER) $(DOCKER_OPT_DOCKER) $(DOCKER_OPT_KVM) $(DEV_IMAGE) bash -# Build the UKI and boot disk for a specific architecture -# This creates: UKI, disk image with EFI boot structure -tools/build-uki/%/boot.disk: 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/keys/db.crt +# 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. +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 -boot-%: tools/qemu-test/ec2-metadata-mock-linux-amd64 tools/build-uki/%/boot.disk user-data.json - $(DOCKER_RUN) -e ARCH=$* $(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 - +# 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. tools/build-uki/%/stage1: docker-build-base mkdir -p tools/build-uki/$* $(DOCKER_RUN) -e ARCH=$* $(DOCKER_SAMEUSER) $(BUILD_IMAGE) \ - bash -c "rustup target add $*-unknown-linux-musl && cargo build --release --locked --all --target $*-unknown-linux-musl" - cp target/$*-unknown-linux-musl/release/stage1 $@ + bash -c "rustup target add $*-unknown-linux-musl && cargo build --release --locked --workspace --exclude mkuki --target $*-unknown-linux-musl && cp -v target/$*-unknown-linux-musl/release/stage1 $@" + +# mkuki assembles the UKI from inside build.sh. Unlike stage1 (which runs on the +# target arch), mkuki is a build-host tool that runs in the x86_64 build container +# regardless of the UKI's target arch, so it is built once for the host musl target. +tools/build-uki/mkuki: docker-build-base + $(DOCKER_RUN) $(DOCKER_SAMEUSER) $(BUILD_IMAGE) \ + bash -c "cargo build --release --locked -p mkuki --target x86_64-unknown-linux-musl && cp -v target/x86_64-unknown-linux-musl/release/mkuki $@" ##################################################################### @@ -156,11 +168,10 @@ tools/build-uki/%/stage1: docker-build-base STAGE0_DIR = crates/stage0 -# Guard the arch-less forms: without these, `make boot-stage0` would match the -# generic `boot-%` pattern (stem "stage0") and try to build a UKI for a bogus -# architecture named "stage0". Require an explicit arch suffix instead. -.PHONY: stage0 boot-stage0 test-stage0 -stage0 boot-stage0 test-stage0: +# 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 @@ -170,8 +181,7 @@ stage0 boot-stage0 test-stage0: tools/build-stage0/%/stage0.efi: docker-build-base 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 $(STAGE0_DIR)/Cargo.toml --target $*-unknown-uefi" - cp $(STAGE0_DIR)/target/$*-unknown-uefi/release/stage0.efi $@ + 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 @@ -180,21 +190,54 @@ tools/build-stage0/%/boot.disk: tools/build-stage0/%/stage0.efi tools/build-uki/ stage0-amd64 stage0-x86_64: tools/build-stage0/x86_64/boot.disk stage0-arm64 stage0-aarch64: tools/build-stage0/aarch64/boot.disk -# Boot stage0 under QEMU. Pass PAYLOAD=path/to/payload.efi (repo-relative) to -# serve a local UEFI payload at http://10.0.2.1:8000/payload.efi; otherwise -# point user-data.stage0.json at any URL reachable from the guest. -# Set TRACE=1 to capture the guest TCP conversation to stage0-trace.txt (needs -# the dev image rebuilt for tcpdump: 'make docker-build-dev'). -boot-stage0-%: tools/qemu-test/ec2-metadata-mock-linux-amd64 tools/build-stage0/%/boot.disk user-data.stage0.json - $(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 --arch $* $(if $(PAYLOAD),--payload $(PAYLOAD)) $(if $(TRACE),--trace) +# 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'). +# +# 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 _stage0 `ed25519` field. +# 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 $(DOCKER_RUN) $(DOCKER_SAMEUSER) $(BUILD_IMAGE) bash -c "\ @@ -205,11 +248,8 @@ tools/build-stage0/keys/release.pem: docker-build-base # 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. -# Hostname (not an IP literal) so the end-to-end test also exercises EFI_DNS4; -# boot.sh maps payload.lockboot.test -> 10.0.2.1 in the QEMU DNS. Override with -# PAYLOAD_URL=http://10.0.2.1:8000/payload.efi to skip DNS. -PAYLOAD_URL ?= http://payload.lockboot.test:8000/payload.efi +# 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) \ @@ -218,15 +258,34 @@ tools/build-stage0/%/payload.efi: docker-build-base tools/build-stage0/keys/rele 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" -# One-shot end-to-end test: build + sign the payload, pin the release pubkey into -# a _stage0 user-data doc (signed mode), then boot stage0 serving the payload -# and its detached .sig locally over HTTP. -test-stage0-%: tools/build-stage0/%/payload.efi tools/build-stage0/%/boot.disk tools/qemu-test/ec2-metadata-mock-linux-amd64 - @PUB=$$(cat tools/build-stage0/keys/release.pub.b64); \ - printf '{\n "_stage0": {\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)" - $(MAKE) boot-stage0-$* PAYLOAD=tools/build-stage0/$*/payload.efi TRACE=$(TRACE) +# 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 $@" + +# 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"; \ + 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); \ + 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) ##################################################################### diff --git a/README.md b/README.md index afda676..ade853c 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ A secure two-stage boot system using the TPM (and AWS Nitro, if available) for v 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: ```bash -make boot-x86_64 boot-aarch64 +make boot-stage0-x86_64 ``` ## Configuration Format diff --git a/crates/mkuki/Cargo.toml b/crates/mkuki/Cargo.toml new file mode 100644 index 0000000..451235f --- /dev/null +++ b/crates/mkuki/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "mkuki" +version = "0.1.0" +edition = "2021" +license.workspace = true +description = "Build a stage0-bootable Unified Kernel Image from a kernel + container rootfs, no binutils/systemd required." + +# The crate is both a library (import the UKI/cpio/sign building blocks) and the +# `mkuki` CLI that drives them. Both targets are named `mkuki`. +[lib] +name = "mkuki" +path = "src/lib.rs" + +[[bin]] +name = "mkuki" +path = "src/main.rs" + +[dependencies] +anyhow = "1.0" +clap = { version = "4", features = ["derive"] } +flate2 = "1" +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. +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. +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } diff --git a/crates/mkuki/README.md b/crates/mkuki/README.md new file mode 100644 index 0000000..75caae3 --- /dev/null +++ b/crates/mkuki/README.md @@ -0,0 +1,88 @@ +# mkuki — kernel + container → stage0-bootable UKI + +`mkuki` turns **a kernel image + a container root filesystem** into a signed +[Unified Kernel Image](https://uapi-group.org/specifications/specs/unified_kernel_image/) +(UKI) that [`stage0`](../stage0/README.md) can download, measure, and chain-load. + +## Why this exists + +`stage0` is a measured UEFI netboot anchor: it admits a payload (by pinned +sha256 **or** an ed25519 signature against a pinned release key), measures it +into **PCR 14**, and `StartImage`s it. + +## Usage + +```sh +mkuki \ + --kernel vmlinuz \ + --stub stub.efi \ # systemd-boot stub (see below) + --from-docker my-registry/app:1.2.3 \ # or: --rootfs ./rootfs (dir or tar) + --cmdline "console=ttyS0,115200n8 ro lockdown=confidentiality" \ + --arch x86_64 \ + --sign-key release.pem \ # PKCS#8 ed25519; writes linux.efi.sig + --url https://cdn.example.com/releases/app-1.2.3.efi \ + --out linux.efi +``` + +This writes `linux.efi`, `linux.efi.sig` (64-byte detached ed25519), prints the +UKI's sha256, and prints the ready-to-paste `_stage1` metadata snippet. + +Rootfs input, pick one: +- `--from-docker IMAGE` — runs `docker create` + `docker export` for you + (use `--docker podman` for another engine). +- `--rootfs ` — a directory tree, or a (optionally gzipped) tar such as + `docker export`/`docker save`-layer output. Engine-agnostic: works with + buildah, kaniko, etc. + +The **systemd-boot stub** (`--stub`) is the only external artifact you need; grab +`stub.efi` from a `systemd-boot-unsigned` package (the repo's +`tools/build-uki/Makefile` already extracts one per arch). systemd v256+ is +recommended (see the project notes on stub VMA handling). + +## Admission modes + +| Mode | Flag | `_stage1` field | Rollover | +|---|---|---|---| +| **signed** | `--sign-key release.pem` | `ed25519: ` | re-sign new builds under the same pinned key; **no metadata edit** | +| **sha256** | (omit `--sign-key`) | `sha256: ` | re-pin the hash on every build | + +The signature is a raw detached ed25519 over the entire UKI, and the printed +pubkey is the base64 of the 32-byte public key — exactly what +[`stage0`'s admission check](../stage0/src/sig.rs) verifies. + +## The stage0 payload contract (what DIY owes) + +A payload booted via `stage0` must: + +1. **Be a UEFI PE.** A `mkuki` UKI satisfies this. (You could ship any EFI app.) +2. **Be admitted** — pinned sha256, or an ed25519 `.sig` at `.sig` against + the pinned release key. +3. **Carry its own init.** The UKI's initramfs provides PID 1; `mkuki` packs your + container rootfs verbatim — your `init`/entrypoint must set up `/proc`, `/sys`, + networking, etc. (`stage1` is what normally does this for you.) +4. **Bake its own cmdline.** `--cmdline` is embedded and immutable at boot — put + your hardening flags here. + +What you get for free from `stage0` regardless: a `db`-signed, Secure-Boot-locked +anchor, and **PCR 14 = sha256(your UKI)**, so the boot stays attestable to your +image hash. What you give up vs `stage1`: auto metadata-config pull, the +pre-execution attestation document, and the PCR 15 config measurement — build +those into your image if you need them. + +## Reproducibility + +Output is byte-deterministic for identical inputs: cpio entries are sorted +bytewise, stamped `uid=gid=0`, `mtime=0`, sequential inodes; gzip headers carry +no mtime/filename; PE section VMAs are derived from the stub. The generated +`.osrel` records a `BUILD_ID` over the component hashes. Same inputs → same UKI → +same PCR 14. + +## Caveats + +- **Everything is loaded into RAM.** `stage0` pulls the whole payload over TCP4 + into memory, and an initramfs unpacks into tmpfs (~2× the rootfs in RAM). Fine + for a slim container; for large images, ship a tiny initramfs that mounts an + erofs/squashfs rootfs instead of packing the whole tree into `.initrd`. +- `mkuki` does **not** `db`/Authenticode-sign the UKI (the optional-header + checksum is zeroed). That path is only needed for firmware-direct boot, not for + `stage0` admission; `sbsign` it separately if you also boot it off an ESP. diff --git a/crates/mkuki/src/cpio.rs b/crates/mkuki/src/cpio.rs new file mode 100644 index 0000000..9cc065f --- /dev/null +++ b/crates/mkuki/src/cpio.rs @@ -0,0 +1,267 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Reproducible newc ("070701") cpio writer for the `.initrd` section. +//! +//! Entries are buffered, sorted bytewise by path, and stamped with sequential +//! inodes and `mtime = 0` so the same rootfs always yields byte-identical +//! output (and therefore a stable PCR 14 measurement once wrapped in the UKI). + +use std::collections::BTreeMap; +use std::io::{Read, Write}; +use std::path::Path; + +use anyhow::{bail, Context, Result}; +use walkdir::WalkDir; + +// st_mode type bits. +const S_IFREG: u32 = 0o100000; +const S_IFDIR: u32 = 0o040000; +const S_IFLNK: u32 = 0o120000; +const S_IFCHR: u32 = 0o020000; +const S_IFBLK: u32 = 0o060000; +const S_IFIFO: u32 = 0o010000; + +struct Entry { + /// Normalized archive path (no leading `/` or `./`). + name: String, + mode: u32, + nlink: u32, + rdevmajor: u32, + rdevminor: u32, + /// File contents, or the target for a symlink; empty otherwise. + data: Vec, +} + +/// Accumulates entries and renders a deterministic newc archive. +#[derive(Default)] +pub struct CpioBuilder { + // Keyed by name so duplicates (later layers winning) collapse and ordering + // is bytewise-sorted for free. + entries: BTreeMap, +} + +impl CpioBuilder { + pub fn new() -> Self { + Self::default() + } + + /// Ingest a flat root filesystem from a directory tree. + pub fn add_dir(&mut self, root: &Path) -> Result<()> { + use std::os::unix::fs::MetadataExt; + use std::os::unix::fs::FileTypeExt; + + for dent in WalkDir::new(root).min_depth(1).sort_by_file_name() { + let dent = dent?; + let rel = dent.path().strip_prefix(root)?; + let name = match normalize(&rel.to_string_lossy()) { + Some(n) => n, + None => continue, + }; + let md = dent.path().symlink_metadata()?; + let perms = md.mode() & 0o7777; + let ft = md.file_type(); + + let entry = if ft.is_symlink() { + let target = std::fs::read_link(dent.path())?; + Entry::new(name, S_IFLNK | 0o777, 1, target.to_string_lossy().as_bytes().to_vec()) + } else if ft.is_dir() { + Entry::new(name, S_IFDIR | perms, 2, Vec::new()) + } else if ft.is_file() { + let data = std::fs::read(dent.path())?; + Entry::new(name, S_IFREG | perms, 1, data) + } else if ft.is_char_device() { + Entry::dev(name, S_IFCHR | perms, md.rdev()) + } else if ft.is_block_device() { + Entry::dev(name, S_IFBLK | perms, md.rdev()) + } else if ft.is_fifo() { + Entry::new(name, S_IFIFO | perms, 1, Vec::new()) + } else { + // Sockets and anything else have no place in an initramfs. + continue; + }; + self.insert(entry); + } + Ok(()) + } + + /// Ingest a flat root filesystem from a tar stream (e.g. `docker export`). + pub fn add_tar(&mut self, reader: R) -> Result<()> { + use tar::EntryType; + + let mut ar = tar::Archive::new(reader); + // Remember regular-file contents so hardlink entries (which carry no + // data) can be materialized as copies. + let mut file_data: BTreeMap> = BTreeMap::new(); + + for entry in ar.entries().context("reading tar entries")? { + let mut e = entry?; + let header = e.header().clone(); + let path = e.path()?.to_string_lossy().into_owned(); + let name = match normalize(&path) { + Some(n) => n, + None => continue, + }; + let perms = header.mode()? & 0o7777; + + let built = match header.entry_type() { + EntryType::Directory => Entry::new(name.clone(), S_IFDIR | perms, 2, Vec::new()), + EntryType::Symlink => { + let target = header + .link_name()? + .context("symlink without target")? + .to_string_lossy() + .into_owned(); + Entry::new(name.clone(), S_IFLNK | 0o777, 1, target.into_bytes()) + } + EntryType::Link => { + // Hardlink: copy the data of the already-seen target. + let target = header + .link_name()? + .context("hardlink without target")? + .to_string_lossy() + .into_owned(); + let tgt = normalize(&target).unwrap_or(target); + let data = file_data.get(&tgt).cloned().unwrap_or_default(); + Entry::new(name.clone(), S_IFREG | perms, 1, data) + } + EntryType::Char => Entry::dev_split( + name.clone(), + S_IFCHR | perms, + header.device_major()?.unwrap_or(0), + header.device_minor()?.unwrap_or(0), + ), + EntryType::Block => Entry::dev_split( + name.clone(), + S_IFBLK | perms, + header.device_major()?.unwrap_or(0), + header.device_minor()?.unwrap_or(0), + ), + EntryType::Fifo => Entry::new(name.clone(), S_IFIFO | perms, 1, Vec::new()), + EntryType::Regular | EntryType::Continuous => { + let mut data = Vec::new(); + e.read_to_end(&mut data)?; + file_data.insert(name.clone(), data.clone()); + Entry::new(name.clone(), S_IFREG | perms, 1, data) + } + // PAX/GNU metadata entries are consumed by the tar crate; ignore + // any other exotic types. + _ => continue, + }; + self.insert(built); + } + Ok(()) + } + + fn insert(&mut self, e: Entry) { + self.entries.insert(e.name.clone(), e); + } + + /// Render the archive bytes (uncompressed). + pub fn finish(&self) -> Result> { + if self.entries.is_empty() { + bail!("rootfs produced no cpio entries"); + } + let mut out = Vec::new(); + // ino 0 is reserved by convention; start at 1. + for (ino, entry) in self.entries.values().enumerate() { + entry.write(&mut out, ino as u32 + 1)?; + } + write_trailer(&mut out)?; + Ok(out) + } +} + +impl Entry { + fn new(name: String, mode: u32, nlink: u32, data: Vec) -> Self { + Entry { name, mode, nlink, rdevmajor: 0, rdevminor: 0, data } + } + + fn dev(name: String, mode: u32, rdev: u64) -> Self { + // Linux dev_t encoding (glibc gnu_dev_major/minor). + let major = (((rdev >> 8) & 0xfff) | ((rdev >> 32) & !0xfffu64)) as u32; + let minor = ((rdev & 0xff) | ((rdev >> 12) & !0xffu64)) as u32; + Entry { name, mode, nlink: 1, rdevmajor: major, rdevminor: minor, data: Vec::new() } + } + + fn dev_split(name: String, mode: u32, major: u32, minor: u32) -> Self { + Entry { name, mode, nlink: 1, rdevmajor: major, rdevminor: minor, data: Vec::new() } + } + + fn write(&self, out: &mut Vec, ino: u32) -> Result<()> { + let namesize = self.name.len() as u32 + 1; // includes trailing NUL + let filesize = self.data.len() as u32; + + out.extend_from_slice(b"070701"); + for field in [ + ino, + self.mode, + 0, // uid: normalize to root for reproducibility + 0, // gid + self.nlink, + 0, // mtime: zeroed for reproducibility + filesize, + 0, // devmajor (the containing fs; irrelevant for initramfs) + 0, // devminor + self.rdevmajor, + self.rdevminor, + namesize, + 0, // check (unused for newc) + ] { + write_hex8(out, field); + } + out.extend_from_slice(self.name.as_bytes()); + out.push(0); + pad4(out); // header(110) + name is aligned to 4 + + out.extend_from_slice(&self.data); + pad4(out); + Ok(()) + } +} + +fn write_trailer(out: &mut Vec) -> Result<()> { + let name = b"TRAILER!!!"; + out.extend_from_slice(b"070701"); + for field in [0u32, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, name.len() as u32 + 1, 0] { + write_hex8(out, field); + } + out.extend_from_slice(name); + out.push(0); + pad4(out); + Ok(()) +} + +fn write_hex8(out: &mut Vec, v: u32) { + // Lowercase 8-digit hex; the kernel's newc parser is case-insensitive. + out.extend_from_slice(format!("{v:08x}").as_bytes()); +} + +fn pad4(out: &mut Vec) { + while !out.len().is_multiple_of(4) { + out.push(0); + } +} + +/// Strip leading `/` and `./`, collapse to a clean relative path; `None` for the +/// root entry (which the kernel does not need). +fn normalize(path: &str) -> Option { + let p = path.trim_start_matches("./").trim_start_matches('/'); + let p = p.trim_end_matches('/'); + if p.is_empty() || p == "." { + None + } else { + Some(p.to_string()) + } +} + +/// Gzip with a zeroed header (no mtime, no filename) for reproducibility. +pub fn gzip(data: &[u8]) -> Result> { + use flate2::{Compression, GzBuilder}; + let mut buf = Vec::new(); + { + let mut enc = GzBuilder::new().mtime(0).write(&mut buf, Compression::best()); + enc.write_all(data)?; + enc.finish()?; + } + Ok(buf) +} diff --git a/crates/mkuki/src/lib.rs b/crates/mkuki/src/lib.rs new file mode 100644 index 0000000..9c6e01d --- /dev/null +++ b/crates/mkuki/src/lib.rs @@ -0,0 +1,217 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! `mkuki` — assemble a kernel + container rootfs into a stage0-bootable Unified +//! Kernel Image, with no binutils/objcopy/ukify or systemd on the build host. +//! +//! This crate is both a CLI (`src/main.rs`) and a library. As a library it +//! exposes two layers: +//! +//! - the low-level building blocks — [`cpio`] (reproducible newc writer), +//! [`uki`] (in-process PE section grafting), and [`sign`] (ed25519/sha256 +//! matching stage0's admission check); and +//! - the higher-level steps the CLI is itself built from — [`build_initramfs`] +//! (ordered layers → `.initrd` + per-layer hashes), [`assemble`] (sections → +//! UKI), [`generate_os_release`], and [`docker_export`]. +//! +//! Logging: library functions emit [`tracing`] events and never install a +//! subscriber, so importers keep control of their own logging. The CLI installs +//! one in `main.rs`. + +pub mod cpio; +pub mod sign; +pub mod uki; + +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{bail, Context, Result}; +use tracing::{debug, info}; + +/// One initramfs layer's input: a directory tree, or the (already-decompressed) +/// bytes of a tar such as `docker export` output. +pub enum LayerSource { + Dir(PathBuf), + Tar(Vec), +} + +impl LayerSource { + /// Resolve a path to a source: a directory as-is, or a tar file (transparently + /// gunzipped if gzipped — the same sniff used for `--rootfs`/`--layer` input). + pub fn from_path(path: &Path) -> Result { + if path.is_dir() { + return Ok(LayerSource::Dir(path.to_path_buf())); + } + let raw = std::fs::read(path) + .with_context(|| format!("reading layer {}", path.display()))?; + if raw.starts_with(&[0x1f, 0x8b]) { + let mut dec = flate2::read::GzDecoder::new(&raw[..]); + let mut tar = Vec::new(); + dec.read_to_end(&mut tar)?; + Ok(LayerSource::Tar(tar)) + } else { + Ok(LayerSource::Tar(raw)) + } + } +} + +/// A named initramfs layer. The `label` is display/metadata only (it appears in +/// the per-layer hash output and the generated os-release `BUILD_ID`); layer +/// *order* is significant — later layers overlay earlier ones at unpack time. +pub struct Layer { + pub label: String, + pub source: LayerSource, +} + +impl Layer { + pub fn new(label: impl Into, source: LayerSource) -> Self { + Layer { label: label.into(), source } + } + + /// Build a layer from a path, deriving the label from the file name (up to the + /// first `.`, e.g. `layers/platform` -> "platform", `userland.cpio.gz` -> + /// "userland"). + pub fn from_path(path: &Path) -> Result { + Ok(Layer::new(layer_label(path), LayerSource::from_path(path)?)) + } +} + +/// The assembled initramfs: the concatenated `.initrd` bytes plus the per-layer +/// sha256 hashes (label, hex) in layer order. +pub struct Initramfs { + pub data: Vec, + pub layer_hashes: Vec<(String, String)>, +} + +/// Build the `.initrd` from ordered `layers`. Each layer becomes its own +/// reproducible, independently-gzipped cpio (each emits its own `TRAILER!!!`, so +/// layers concatenate cleanly) and the kernel unpacks the concatenation as one +/// rootfs — the early-microcode pattern — with later layers overlaying earlier +/// ones. +pub fn build_initramfs(layers: &[Layer]) -> Result { + info!(layers = layers.len(), "building initramfs"); + let mut data: Vec = Vec::new(); + let mut layer_hashes: Vec<(String, String)> = Vec::with_capacity(layers.len()); + for layer in layers { + let gz = build_layer(&layer.source)?; + let sha = sign::sha256_hex(&gz); + debug!(label = %layer.label, %sha, bytes = gz.len(), "layer built"); + layer_hashes.push((layer.label.clone(), sha)); + data.extend_from_slice(&gz); + } + Ok(Initramfs { data, layer_hashes }) +} + +/// The sections that make up a UKI, as already-read bytes. Slices are borrowed so +/// callers can assemble without extra copies. +pub struct UkiSpec<'a> { + /// systemd-boot stub PE to graft sections onto. + pub stub: &'a [u8], + /// Kernel image (`.linux`). + pub kernel: &'a [u8], + /// Concatenated initramfs (`.initrd`). + pub initrd: &'a [u8], + /// os-release contents (`.osrel`). + pub os_release: &'a [u8], + /// Kernel command line (`.cmdline`), immutable at boot. + pub cmdline: &'a [u8], + /// Optional kernel version string (`.uname`, display only). + pub uname: Option<&'a str>, +} + +/// Assemble a UKI from `spec`, grafting the sections onto the stub in the fixed +/// ascending-VMA order (`.osrel`, `.cmdline`, optional `.uname`, `.linux`, +/// `.initrd`). Returns the new PE bytes. +pub fn assemble(spec: &UkiSpec) -> Result> { + info!("assembling UKI"); + let mut sections = vec![ + uki::Section { name: ".osrel", data: spec.os_release }, + uki::Section { name: ".cmdline", data: spec.cmdline }, + ]; + if let Some(u) = spec.uname { + sections.push(uki::Section { name: ".uname", data: u.as_bytes() }); + } + sections.push(uki::Section { name: ".linux", data: spec.kernel }); + sections.push(uki::Section { name: ".initrd", data: spec.initrd }); + uki::build(spec.stub, §ions) +} + +/// Build one layer into an independently-gzipped, reproducible newc cpio archive. +fn build_layer(src: &LayerSource) -> Result> { + let mut b = cpio::CpioBuilder::new(); + match src { + LayerSource::Dir(p) => b.add_dir(p)?, + LayerSource::Tar(bytes) => b.add_tar(&bytes[..])?, + } + cpio::gzip(&b.finish()?) +} + +/// Short, stable label for a layer from its path: the file name up to the first +/// `.`, falling back to "layer". +fn layer_label(p: &Path) -> String { + p.file_name() + .map(|s| s.to_string_lossy().split('.').next().unwrap_or("layer").to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "layer".to_string()) +} + +/// `docker create` + `docker export` a flat rootfs tar, then clean up. `engine` +/// is the container binary (e.g. `docker`, `podman`). +pub fn docker_export(engine: &str, image: &str) -> Result> { + info!(%image, %engine, "exporting rootfs from container image"); + let create = Command::new(engine) + .args(["create", image]) + .output() + .with_context(|| format!("running `{engine} create`"))?; + if !create.status.success() { + bail!("`{engine} create {image}` failed: {}", String::from_utf8_lossy(&create.stderr)); + } + let cid = String::from_utf8(create.stdout)?.trim().to_string(); + + let export = Command::new(engine) + .args(["export", &cid]) + .output() + .with_context(|| format!("running `{engine} export`"))?; + + // Best-effort cleanup regardless of export result. + let _ = Command::new(engine).args(["rm", "-f", &cid]).output(); + + if !export.status.success() { + bail!("`{engine} export {cid}` failed: {}", String::from_utf8_lossy(&export.stderr)); + } + Ok(export.stdout) +} + +/// Minimal os-release with a BUILD_ID derived from the component hashes, so a +/// rebuild from identical inputs is traceable (mirrors build.sh). The layer +/// hashes already pin the initrd: the `.initrd` section is the deterministic +/// concatenation of those layers, so we describe it from them rather than +/// re-hashing the concatenation. +pub fn generate_os_release( + id: &str, + kernel: &[u8], + cmdline: &[u8], + layer_hashes: &[(String, String)], +) -> String { + let short_d = |d: &[u8]| sign::sha256_hex(d)[..8].to_string(); + // Single source (--rootfs/--from-docker): the lone layer hash IS the initrd + // hash, so emit `.initrd-` — byte-identical to the pre-layering format. + // Multiple layers: record each individually for independent verifiability. + let initrd: String = if layer_hashes.len() == 1 { + format!(".initrd-{}", &layer_hashes[0].1[..8]) + } else { + layer_hashes + .iter() + .map(|(label, h)| format!(".{label}-{}", &h[..8])) + .collect() + }; + let build_id = format!( + "krnl-{}.args-{}{}", + short_d(kernel), + short_d(cmdline), + initrd, + ); + format!( + "ID={id}\nNAME=\"{id}\"\nPRETTY_NAME=\"{id}\"\nBUILD_ID={build_id}\n" + ) +} diff --git a/crates/mkuki/src/main.rs b/crates/mkuki/src/main.rs new file mode 100644 index 0000000..fd90fea --- /dev/null +++ b/crates/mkuki/src/main.rs @@ -0,0 +1,200 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! `mkuki` — turn a kernel + container rootfs into a stage0-bootable Unified +//! Kernel Image, with no binutils/objcopy/ukify or systemd on the build host. +//! +//! This is the command-line front end: it parses arguments, installs the tracing +//! subscriber, and drives the [`mkuki`] library, which holds all the assembly +//! logic so it can also be reused directly. It assembles a systemd-boot-stub UKI +//! in-process, optionally signs it with the ed25519 release key stage0 admits +//! with, and prints the UKI/layer hashes. The resulting `.efi` is a plain UEFI +//! payload — nothing in it depends on stage1. + +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context, Result}; +use clap::Parser; +use tracing::info; +use tracing_subscriber::{filter::LevelFilter, EnvFilter}; + +use mkuki::{ + assemble, build_initramfs, docker_export, generate_os_release, sign, Layer, LayerSource, + UkiSpec, +}; + +/// Build a stage0-bootable UKI from a kernel + container rootfs. +#[derive(Parser, Debug)] +#[command(name = "mkuki", version, about, long_about = None)] +struct Args { + /// Kernel image to embed (.linux), e.g. a specific vmlinuz. + #[arg(long, value_name = "vmlinuz")] + kernel: PathBuf, + + /// systemd-boot stub PE to graft sections onto (.efi). + #[arg(long, value_name = "stub.efi")] + stub: PathBuf, + + /// Root filesystem for the initramfs: a directory, or a (optionally gzipped) + /// tar such as `docker export` output. Mutually exclusive with --from-docker. + #[arg(long, value_name = "dir|tar")] + rootfs: Option, + + /// Container image to export as the rootfs (runs `docker create`+`export`). + /// Use --docker to point at podman/nerdctl instead. + #[arg(long, value_name = "image", conflicts_with = "rootfs")] + from_docker: Option, + + /// Container engine binary for --from-docker. + #[arg(long, default_value = "docker")] + docker: String, + + /// Initramfs layer (dir or tar), repeatable; order is significant (the first + /// --layer is the first concatenated cpio). Use instead of --rootfs/--from-docker + /// for layered assembly, e.g. `--layer platform --layer userland`. The kernel + /// concatenates the independently-gzipped layers at unpack time. + #[arg(long = "layer", value_name = "dir|tar", conflicts_with_all = ["rootfs", "from_docker"])] + layers: Vec, + + /// Kernel command line baked into the UKI (.cmdline). Immutable at boot. + #[arg(long, default_value = "")] + cmdline: String, + + /// Kernel version string for the .uname section (optional, display only). + #[arg(long)] + uname: Option, + + /// os-release file to embed (.osrel). If omitted, a minimal one is generated. + #[arg(long, value_name = "file")] + os_release: Option, + + /// ID= for the generated os-release (when --os-release is not given). + #[arg(long, default_value = "diy")] + id: String, + + /// Output UKI path (.efi). + #[arg(long, value_name = "linux.efi")] + out: PathBuf, + + /// PKCS#8 ed25519 private key (PEM) to sign the UKI. Writes .sig. + #[arg(long, value_name = "release.pem")] + sign_key: Option, + + /// Target architecture, used only to label the printed _stage1 snippet. + #[arg(long, default_value = "x86_64")] + arch: String, + + /// Increase log verbosity: -v for debug, -vv for trace. Overridden by RUST_LOG. + #[arg(short, long, action = clap::ArgAction::Count)] + verbose: u8, + + /// Silence everything below errors. Overridden by RUST_LOG. + #[arg(short, long, conflicts_with = "verbose")] + quiet: bool, +} + +fn main() -> Result<()> { + let args = Args::parse(); + init_tracing(args.verbose, args.quiet); + + // Resolve the ordered list of initramfs layers. Either explicit --layer inputs + // (layered assembly), or a single implicit layer from --rootfs/--from-docker + // (back-compat: byte-identical to the pre-layering output, so the lone layer + // keeps the label "rootfs" regardless of the input path). + let layers = resolve_layers(&args)?; + + // --- initramfs (.initrd) --- + let initrd = build_initramfs(&layers)?; + + // --- kernel (.linux) --- + let kernel = std::fs::read(&args.kernel) + .with_context(|| format!("reading kernel {}", args.kernel.display()))?; + + // --- os-release (.osrel) --- + let osrel = match &args.os_release { + Some(p) => std::fs::read(p).with_context(|| format!("reading {}", p.display()))?, + None => generate_os_release(&args.id, &kernel, args.cmdline.as_bytes(), &initrd.layer_hashes) + .into_bytes(), + }; + + // --- assemble UKI --- + let stub = std::fs::read(&args.stub) + .with_context(|| format!("reading stub {}", args.stub.display()))?; + let image = assemble(&UkiSpec { + stub: &stub, + kernel: &kernel, + initrd: &initrd.data, + os_release: &osrel, + cmdline: args.cmdline.as_bytes(), + uname: args.uname.as_deref(), + })?; + std::fs::write(&args.out, &image) + .with_context(|| format!("writing UKI {}", args.out.display()))?; + info!(path = %args.out.display(), bytes = image.len(), "wrote UKI"); + + // --- admission material --- + let sha = sign::sha256_hex(&image); + if let Some(key_path) = &args.sign_key { + let pem = std::fs::read_to_string(key_path) + .with_context(|| format!("reading signing key {}", key_path.display()))?; + let s = sign::sign_payload(&pem, &image)?; + let sig_path = sig_path(&args.out); + std::fs::write(&sig_path, &s.signature) + .with_context(|| format!("writing {}", sig_path.display()))?; + info!(path = %sig_path.display(), "wrote detached ed25519 signature (64 bytes)"); + } + + // Machine-readable result on stdout — logging goes to stderr, so this stays + // clean for scripts that capture the hashes. + println!("\nsha256: {sha}"); + for (label, h) in &initrd.layer_hashes { + println!("layer {label}: {h}"); + } + Ok(()) +} + +/// Resolve the CLI's rootfs flags into the ordered layer list the library builds. +fn resolve_layers(args: &Args) -> Result> { + if !args.layers.is_empty() { + args.layers.iter().map(|p| Layer::from_path(p)).collect() + } else if let Some(image) = &args.from_docker { + let tar = docker_export(&args.docker, image)?; + Ok(vec![Layer::new("rootfs", LayerSource::Tar(tar))]) + } else if let Some(rootfs) = &args.rootfs { + // Force the label "rootfs" (not the path-derived one) so the single-layer + // os-release stays byte-identical to the pre-layering output. + Ok(vec![Layer::new("rootfs", LayerSource::from_path(rootfs)?)]) + } else { + bail!("provide --layer (repeatable), --rootfs , or --from-docker "); + } +} + +/// Install the tracing subscriber: events go to stderr (stdout stays reserved for +/// the machine-readable hash output). `RUST_LOG` wins when set; otherwise the +/// level comes from `-v`/`-q`. +fn init_tracing(verbose: u8, quiet: bool) { + let default = if quiet { + LevelFilter::ERROR + } else { + match verbose { + 0 => LevelFilter::INFO, + 1 => LevelFilter::DEBUG, + _ => LevelFilter::TRACE, + } + }; + let filter = EnvFilter::builder() + .with_default_directive(default.into()) + .from_env_lossy(); + tracing_subscriber::fmt() + .with_env_filter(filter) + .with_target(false) + .without_time() + .with_writer(std::io::stderr) + .init(); +} + +/// `.sig` — where stage0 expects the detached signature. +fn sig_path(out: &Path) -> PathBuf { + let mut s = out.as_os_str().to_os_string(); + s.push(".sig"); + PathBuf::from(s) +} diff --git a/crates/mkuki/src/sign.rs b/crates/mkuki/src/sign.rs new file mode 100644 index 0000000..139a198 --- /dev/null +++ b/crates/mkuki/src/sign.rs @@ -0,0 +1,69 @@ +// 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. + +use anyhow::{ensure, Context, Result}; +use base64::engine::general_purpose::STANDARD; +use base64::Engine as _; +use ed25519_compact::{KeyPair, Seed}; +use sha2::{Digest, Sha256}; + +pub fn sha256_hex(data: &[u8]) -> String { + let digest = Sha256::digest(data); + hex(&digest) +} + +fn hex(bytes: &[u8]) -> String { + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + s.push_str(&format!("{b:02x}")); + } + s +} + +pub struct Signed { + /// 64-byte detached ed25519 signature over the payload. + pub signature: Vec, + /// base64 of the 32-byte public key — paste into `_stage1`'s `ed25519`. + pub pubkey_b64: String, +} + +/// Sign `payload` with the PKCS#8 (PEM) ed25519 private key at `pem`. +pub fn sign_payload(pem: &str, payload: &[u8]) -> Result { + let seed = seed_from_pkcs8_pem(pem)?; + let kp = KeyPair::from_seed(Seed::new(seed)); + let signature = kp.sk.sign(payload, None); + Ok(Signed { + signature: signature.to_vec(), + pubkey_b64: STANDARD.encode(*kp.pk), + }) +} + +/// Extract the 32-byte Ed25519 seed from a PKCS#8 PEM private key +/// (RFC 8410). openssl `genpkey -algorithm ed25519` emits the 48-byte DER: +/// `... 04 22 04 20 <32-byte seed>`, so we locate the inner OCTET STRING. +fn seed_from_pkcs8_pem(pem: &str) -> Result<[u8; 32]> { + let b64: String = pem + .lines() + .filter(|l| !l.starts_with("-----")) + .collect::>() + .concat(); + let der = STANDARD + .decode(b64.trim()) + .context("private key PEM body is not valid base64")?; + + // Find the `04 22 04 20` wrapper (CurvePrivateKey OCTET STRING containing a + // 32-byte OCTET STRING) and take the 32 bytes that follow it. + let marker = [0x04u8, 0x22, 0x04, 0x20]; + if let Some(pos) = der.windows(4).position(|w| w == marker) { + let start = pos + 4; + ensure!(start + 32 <= der.len(), "truncated ed25519 private key"); + let mut seed = [0u8; 32]; + seed.copy_from_slice(&der[start..start + 32]); + return Ok(seed); + } + anyhow::bail!("not a PKCS#8 Ed25519 private key (expected 04 22 04 20 marker)") +} diff --git a/crates/mkuki/src/uki.rs b/crates/mkuki/src/uki.rs new file mode 100644 index 0000000..0653616 --- /dev/null +++ b/crates/mkuki/src/uki.rs @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Append UKI sections (`.osrel`, `.cmdline`, `.uname`, `.linux`, `.initrd`) to +//! a systemd-boot stub PE, in-process — the native equivalent of the +//! `objcopy --add-section ... --change-section-vma ...` dance in +//! `tools/build-uki/build.sh`, with no binutils/ukify dependency. +//! +//! VMAs are computed dynamically by walking the stub's existing sections and +//! appending after the last one (the v256 stubs use high VMAs; see the project +//! notes), matching ukify's placement so the same stub works across systemd +//! versions. + +use anyhow::{bail, ensure, Result}; + +const SECTION_HEADER_SIZE: usize = 40; +const IMAGE_SCN_CNT_INITIALIZED_DATA: u32 = 0x0000_0040; +const IMAGE_SCN_MEM_READ: u32 = 0x4000_0000; + +/// One section to graft onto the stub. Order is preserved and determines the +/// ascending VMA layout. +pub struct Section<'a> { + pub name: &'a str, + pub data: &'a [u8], +} + +struct PeView { + pe_off: usize, // offset of "PE\0\0" + opt_off: usize, // offset of the optional header + sect_table: usize, // offset of the first section header + num_sections: usize, + section_align: u32, + file_align: u32, + size_of_headers: u32, +} + +fn rd_u16(b: &[u8], off: usize) -> u16 { + u16::from_le_bytes([b[off], b[off + 1]]) +} +fn rd_u32(b: &[u8], off: usize) -> u32 { + u32::from_le_bytes([b[off], b[off + 1], b[off + 2], b[off + 3]]) +} +fn wr_u16(b: &mut [u8], off: usize, v: u16) { + b[off..off + 2].copy_from_slice(&v.to_le_bytes()); +} +fn wr_u32(b: &mut [u8], off: usize, v: u32) { + b[off..off + 4].copy_from_slice(&v.to_le_bytes()); +} + +fn align_up(x: u64, a: u64) -> u64 { + debug_assert!(a.is_power_of_two()); + (x + a - 1) & !(a - 1) +} + +impl PeView { + fn parse(b: &[u8]) -> Result { + ensure!(b.len() > 0x40 && &b[0..2] == b"MZ", "not a PE/MZ image"); + let pe_off = rd_u32(b, 0x3c) as usize; + ensure!(pe_off + 24 <= b.len() && &b[pe_off..pe_off + 4] == b"PE\0\0", "bad PE signature"); + let coff = pe_off + 4; + let num_sections = rd_u16(b, coff + 2) as usize; + let size_opt = rd_u16(b, coff + 16) as usize; + let opt_off = coff + 20; + // PE32+ (0x20b) is what UEFI images use; the field offsets below assume it. + let magic = rd_u16(b, opt_off); + ensure!(magic == 0x20b, "expected PE32+ image (magic 0x20b), got {magic:#x}"); + let section_align = rd_u32(b, opt_off + 32); + let file_align = rd_u32(b, opt_off + 36); + let size_of_headers = rd_u32(b, opt_off + 60); + let sect_table = opt_off + size_opt; + ensure!( + sect_table + num_sections * SECTION_HEADER_SIZE <= b.len(), + "section table out of bounds" + ); + Ok(PeView { + pe_off, + opt_off, + sect_table, + num_sections, + section_align, + file_align, + size_of_headers, + }) + } +} + +/// Build a UKI by appending `sections` to `stub`. Returns the new PE bytes. +pub fn build(stub: &[u8], sections: &[Section]) -> Result> { + let pe = PeView::parse(stub)?; + + // Highest VMA end across existing sections — where ours begin. + let mut max_va_end: u64 = 0; + let mut min_raw_ptr: u64 = u64::MAX; + for i in 0..pe.num_sections { + let sh = pe.sect_table + i * SECTION_HEADER_SIZE; + let vsize = rd_u32(stub, sh + 8) as u64; + let vaddr = rd_u32(stub, sh + 12) as u64; + let rsize = rd_u32(stub, sh + 16) as u64; + let rptr = rd_u32(stub, sh + 20) as u64; + max_va_end = max_va_end.max(vaddr + vsize.max(rsize)); + if rptr > 0 { + min_raw_ptr = min_raw_ptr.min(rptr); + } + } + + // The new section headers must fit in the existing header padding, before + // the first section's raw data. systemd stubs leave room; if a future stub + // does not, fail loudly rather than silently corrupting the image. + let new_headers_end = pe.sect_table + (pe.num_sections + sections.len()) * SECTION_HEADER_SIZE; + let header_ceiling = (pe.size_of_headers as u64).min(min_raw_ptr) as usize; + ensure!( + new_headers_end <= header_ceiling, + "no room for {} new section headers (need {} bytes, header area ends at {}); \ + the stub has too little header padding", + sections.len(), + new_headers_end, + header_ceiling + ); + + let mut out = stub.to_vec(); + let file_align = pe.file_align as u64; + let section_align = pe.section_align as u64; + + let mut vma = align_up(max_va_end, section_align); + let mut headers: Vec<[u8; SECTION_HEADER_SIZE]> = Vec::with_capacity(sections.len()); + + for s in sections { + let name_bytes = s.name.as_bytes(); + ensure!(name_bytes.len() <= 8, "section name {:?} exceeds 8 bytes", s.name); + + // Append raw data at an aligned file offset. + let raw_ptr = align_up(out.len() as u64, file_align); + out.resize(raw_ptr as usize, 0); + out.extend_from_slice(s.data); + let raw_size = align_up(s.data.len() as u64, file_align); + out.resize((raw_ptr + raw_size) as usize, 0); + + let mut sh = [0u8; SECTION_HEADER_SIZE]; + sh[..name_bytes.len()].copy_from_slice(name_bytes); + wr_u32(&mut sh, 8, s.data.len() as u32); // VirtualSize + wr_u32(&mut sh, 12, vma as u32); // VirtualAddress + wr_u32(&mut sh, 16, raw_size as u32); // SizeOfRawData + wr_u32(&mut sh, 20, raw_ptr as u32); // PointerToRawData + // PointerToRelocations/Linenumbers + counts stay 0. + wr_u32(&mut sh, 36, IMAGE_SCN_CNT_INITIALIZED_DATA | IMAGE_SCN_MEM_READ); + headers.push(sh); + + vma = align_up(vma + s.data.len() as u64, section_align); + } + + // Write the new headers into the table and bump the section count. + for (i, sh) in headers.iter().enumerate() { + let off = pe.sect_table + (pe.num_sections + i) * SECTION_HEADER_SIZE; + out[off..off + SECTION_HEADER_SIZE].copy_from_slice(sh); + } + let new_count = pe.num_sections + sections.len(); + ensure!(new_count <= u16::MAX as usize, "too many sections"); + wr_u16(&mut out, pe.pe_off + 4 + 2, new_count as u16); + + // SizeOfImage = end of the last section's VMA span, aligned. + wr_u32(&mut out, pe.opt_off + 56, vma as u32); + + // UEFI ignores the optional-header CheckSum for loading, and we are not + // Authenticode-signing here (stage0 admits by ed25519/sha256, not `db`). + // Zero it so no stale/incorrect value is left behind; sbsign would recompute + // it anyway if this UKI were ever also signed for firmware-direct boot. + wr_u32(&mut out, pe.opt_off + 64, 0); + + if out.len() > u32::MAX as usize { + bail!("resulting UKI exceeds 4 GiB"); + } + Ok(out) +} diff --git a/crates/stage0/Cargo.toml b/crates/stage0/Cargo.toml index 7f4da44..98e97f2 100644 --- a/crates/stage0/Cargo.toml +++ b/crates/stage0/Cargo.toml @@ -8,6 +8,12 @@ license = "MIT OR Apache-2.0" # 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"] } diff --git a/crates/stage0/README.md b/crates/stage0/README.md index bc210b4..10a951a 100644 --- a/crates/stage0/README.md +++ b/crates/stage0/README.md @@ -1,112 +1,107 @@ -# stage0 — measured UEFI network bootloader - -`stage0` is a pure-UEFI application (no Linux kernel) that the firmware boots -directly. It downloads and chain-loads another **UEFI** binary over the network, -measuring it into the TPM first. It is the kernel-less sibling of `stage1`: -same metadata-driven, measure-then-execute model, but living entirely in UEFI -boot services. - -## Flow - -1. Bring up the NIC via `EFI_IP4_CONFIG2` (DHCP). -2. Fetch a `_stage0` user-data document from the cloud metadata service over - `EFI_HTTP_PROTOCOL` (EC2 IMDSv2 → GCP → Azure → Aliyun, mirroring `stage1`). -3. Download the per-arch UEFI payload from the pinned URL over raw `EFI_TCP4` - (`src/tcp4.rs`); a hostname URL is resolved via `EFI_DNS4` (`src/dns4.rs`). - Metadata uses `EFI_HTTP` at fixed link-local IPs; the payload uses TCP4. -4. **Admit** the payload by one of two policies (see "Admission & trust"): - - **sha256 mode** — the payload's SHA-256 must equal the value pinned in the - metadata (immutable payload). - - **signed mode** — a detached ed25519 signature fetched from `.sig` must - verify against a long-term release **public key** pinned in the metadata - (the payload can roll forward without editing metadata). -5. Measure into the TPM via `EFI_TCG2_PROTOCOL`: **PCR 14** ← SHA-256(payload). - Nothing else is measured — see "Admission & trust". -6. `LoadImage` (from the memory buffer, via a temporary `FileAuthentication` - override) + `StartImage` to chain-load. - -Integrity/authenticity comes from the pinned hash or signature, so plain HTTP is -used (no reliance on the inconsistently-available `EFI_TLS_PROTOCOL`). - -## Admission & trust - -The attestation surface is deliberately minimal: **the only thing measured is -PCR 14** — "stage0 ran, and it loaded a binary with this hash." The config, the -pinned hash, the release key and the signature are *not* measured. A verifier -just checks PCR 14 against the set of approved release hashes; it does not have -to model the metadata document or key material. (This is why PCR 15 — the config -measurement `stage1` does — is intentionally dropped here.) - -The signature/hash is **admission control only**: it decides whether stage0 is -*willing* to load a payload, not what gets attested. Signed mode exists so a -deployment can pin a long-term release key once and let new builds roll forward -under that key without touching VM metadata; the private key stays offline with -the publisher and never reaches a deployed machine. - -Because the payload is admitted by stage0's own policy rather than the firmware -`db`, stage0 chain-loads it through a temporary **security-arch override** -(`secauth.rs`): it swaps `EFI_SECURITY2_ARCH_PROTOCOL.FileAuthentication` for an -allow-all across a single `LoadImage`, then restores it — exactly shim's -`security_policy_install()`. The firmware still does all real PE loading and -relocation; only the *verdict* is replaced. This is what lets the deployment -keep its lockdown model (a per-release, ephemeral `db` key that signs `stage0` -itself and is then destroyed, with the variable store locked) **and** still -chain-load late-bound payloads — the two are otherwise mutually exclusive, since -an ephemeral, destroyed key cannot sign a payload fetched at boot. - -Note this makes `stage0` a trust anchor *with policy*, not merely a measurer: -it is itself `db`-signed and measured, and everything it loads is measured into -PCR 14, so the chain stays attestable end to end. - -## `_stage0` metadata schema - -Each arch entry carries a `url` plus **exactly one** of `sha256` (pin an exact -hash) or `ed25519` (pin a base64 release public key; the detached signature is -fetched from `.sig`): +# stage0 - measured UEFI network bootloader -```json -{ - "_stage0": { - "args": ["optional", "load-options"], - "x86_64": { "url": "http://…/payload.efi", "sha256": "<64 hex>" }, - "aarch64": { "url": "http://…/payload.efi", "ed25519": "" } - } -} -``` - -## TPM access - -`stage0` reuses `vaportpm-attest` unchanged — that crate funnels all TPM I/O -through its `TpmTransport` trait, so `stage0` supplies a `Tcg2Transport` backed -by `EFI_TCG2_PROTOCOL.SubmitCommand` (`src/tcg2.rs`) and calls the same -`pcr_extend` used on Linux. `stage0` only *measures*; the chained payload (or a -later Linux stage) produces the actual TPM2_Quote. Build the crate with -`--no-default-features` (no_std) for UEFI targets. +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`. -## Build & test +## Using it -```sh -# Build the stage0 .efi (in the build container; vaportpm pulled from git) -make tools/build-stage0/x86_64/stage0.efi # or aarch64 +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: -# Assemble + sign the bootable ESP disk (privileged: losetup/mount) -make tools/build-stage0/x86_64/boot.disk - -# End-to-end under QEMU: builds + ed25519-signs the test payload, pins the -# release pubkey into user-data.stage0.json (signed mode), serves the payload -# and its .sig locally (via a DNS name, exercising EFI_DNS4), and boots stage0. -make test-stage0-x86_64 - -# Or boot an already-built disk, choosing what to boot: -./tools/qemu-test/boot.sh --kind stage0 --arch x86_64 \ - --payload tools/build-stage0/x86_64/payload.efi -./tools/qemu-test/boot.sh --help +```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 + } + } +} ``` -Always include the arch suffix: `make boot-stage0` (no arch) is **not** a target -— it would be misread as a UKI build for an architecture literally named -"stage0". - -The test payload (`crates/stage0-test-payload`) is a trivial chain-loaded UEFI -app that prints a banner and reads back PCR 14/15, confirming the -measure-then-execute path end to end. +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 index c4e5411..1b73d83 100644 --- a/crates/stage0/src/config.rs +++ b/crates/stage0/src/config.rs @@ -1,9 +1,9 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 -//! The `_stage0` metadata schema. +//! The `_stage1` metadata schema. //! //! Mirrors `stage1`'s per-arch `{url, sha256}` structure (plus optional `args`) -//! but under a distinct `_stage0` key, so a UEFI payload is never confused with +//! 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; @@ -14,12 +14,12 @@ use serde::Deserialize; #[derive(Debug, Deserialize)] pub struct UserData { - #[serde(rename = "_stage0")] - pub stage0: Stage0Config, + #[serde(rename = "_stage1")] + pub stage1: Stage1Config, } #[derive(Debug, Deserialize)] -pub struct Stage0Config { +pub struct Stage1Config { #[serde(default)] pub args: Option>, // Exactly one of these is read per build (see `for_this_arch`); the other @@ -43,18 +43,41 @@ pub struct ArchConfig { 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 (`.sig`) must verify against this - /// base64-encoded 32-byte release public key. - Ed25519(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 Stage0Config { +impl Stage1Config { /// The config entry for the architecture stage0 was built for. #[must_use] pub fn for_this_arch(&self) -> Option<&ArchConfig> { @@ -77,16 +100,38 @@ impl ArchConfig { /// Validate the URL and the (single) verification field, returning the /// selected [`Verify`] mode. pub fn validate(&self) -> Result { - if !(self.url.starts_with("http://") || self.url.starts_with("https://")) { - return Err("url must start with http:// or https://"); + // 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"); } @@ -95,7 +140,12 @@ impl ArchConfig { (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.clone())), + 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"), } @@ -106,5 +156,5 @@ impl ArchConfig { /// 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 _stage0 key") + 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 index 8dbccbf..30ff0dd 100644 --- a/crates/stage0/src/dns4.rs +++ b/crates/stage0/src/dns4.rs @@ -2,21 +2,27 @@ //! 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). `tcp4::download` calls [`resolve`] for any +//! 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 server list is taken from DHCP (`UseDefaultSetting = TRUE`), the same -//! lease `http.rs` established to fetch metadata. `uefi-raw` 0.11 does not expose -//! DNS4, so the FFI bindings (UEFI spec, EFI_DNS4_PROTOCOL) are defined here. +//! 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::{println, CString16, Status}; +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) ---- @@ -75,20 +81,21 @@ struct Dns4Sb(ServiceBindingProtocol); #[unsafe_protocol("ae3d28cc-e05b-4fa1-a011-7eb55a3f1401")] struct Dns4(Dns4Protocol); -/// EFI_IP_PROTO_UDP — DNS queries ride UDP. +/// EFI_IP_PROTO_UDP: DNS queries ride UDP. const IP_PROTO_UDP: u8 = 17; -unsafe fn pump(dns: *mut Dns4Protocol, status: *const Status, budget_ms: u32) -> Status { - let mut waited = 0; +/// 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); - boot::stall(1000); - waited += 1; - if waited >= budget_ms { + if crate::timing::since_boot_ms().wrapping_sub(start) >= budget_ms { return Status::TIMEOUT; } } @@ -107,10 +114,43 @@ fn new_event() -> Result { .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| { - println!("stage0: no EFI_DNS4 service binding: {:?}", e.status()); + crate::slog!("stage0: no EFI_DNS4 service binding: {:?}", e.status()); e.status() })?; let mut sb = unsafe { @@ -128,7 +168,7 @@ pub fn resolve(host: &str) -> Result<[u8; 4], 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 { - println!("stage0: EFI_DNS4 create_child failed: {st:?}"); + 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)? }; @@ -153,24 +193,45 @@ fn resolve_on_child(child: uefi::Handle, host: &str) -> Result<[u8; 4], Status> }; let dns_ptr: *mut Dns4Protocol = &mut dns.0; - // Configure with the DHCP-obtained DNS server list (same lease as metadata). - let cfg = 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, + // 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 { - println!("stage0: EFI_DNS4 configure failed: {st:?} (no DHCP-provided DNS server?)"); + 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()?; @@ -193,26 +254,26 @@ fn resolve_on_child(child: uefi::Handle, host: &str) -> Result<[u8; 4], Status> let _ = unsafe { ((*dns_ptr).configure)(dns_ptr, ptr::null()) }; if st != Status::SUCCESS { - println!("stage0: EFI_DNS4 HostNameToIp({host}) failed: {st:?}"); + crate::slog!("stage0: EFI_DNS4 HostNameToIp({host}) failed: {st:?}"); return Err(st); } let h2a = token.rsp_data; if h2a.is_null() { - println!("stage0: EFI_DNS4 returned no response data for {host}"); + 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); - println!("stage0: EFI_DNS4 found no addresses for {host}"); + crate::slog!("stage0: EFI_DNS4 found no addresses for {host}"); return Err(Status::NOT_FOUND); } (*data.ip_list).0 }; unsafe { free_h2a(h2a) }; - println!( + crate::sdbg!( "stage0: resolved {host} -> {}.{}.{}.{}", ip[0], ip[1], ip[2], ip[3] ); diff --git a/crates/stage0/src/embedded.rs b/crates/stage0/src/embedded.rs new file mode 100644 index 0000000..87a49c4 --- /dev/null +++ b/crates/stage0/src/embedded.rs @@ -0,0 +1,86 @@ +// 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 index c11e85a..0d5636b 100644 --- a/crates/stage0/src/http.rs +++ b/crates/stage0/src/http.rs @@ -1,430 +1,146 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 -//! Minimal HTTP/1.0 client over `EFI_HTTP_PROTOCOL`. -//! -//! uefi-rs ships an `HttpHelper`, but its request path only emits a `Host` -//! header. The cloud metadata services need custom headers (IMDSv2 token, -//! `Metadata-Flavor`, `Metadata: true`) and the EC2 token handshake needs -//! `PUT`, so we drive the raw `Http` protocol directly. -//! -//! Integrity of downloaded payloads comes from the SHA-256 pinned in the -//! (trusted) metadata document, so plain HTTP is sufficient and we avoid the -//! inconsistently-available `EFI_TLS_PROTOCOL`. +//! 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::ffi::CString; use alloc::string::String; -use alloc::vec; use alloc::vec::Vec; -use core::ffi::{c_char, c_void, CStr}; -use uefi::boot::{ - self, EventType, OpenProtocolAttributes, OpenProtocolParams, ScopedProtocol, TimerTrigger, Tpl, -}; -use uefi::proto::network::http::{Http, HttpBinding}; -use uefi::proto::network::ip4config2::Ip4Config2; -use uefi::{println, CString16, Event, Handle, Status}; -use uefi_raw::protocol::network::http::{ - HttpAccessPoint, HttpConfigData, HttpHeader, HttpMessage, HttpRequestData, HttpResponseData, - HttpStatusCode, HttpToken, HttpV4AccessPoint, HttpVersion, -}; +use uefi::Status; -pub use uefi_raw::protocol::network::http::HttpMethod; +use crate::tcp4; -/// Body is read one TCP segment at a time. Packet captures show OVMF's HttpDxe -/// pulls exactly one segment into a `Response()` body buffer and then stalls -/// unless the buffer is full (or Content-Length is reached) — it will not drain -/// further buffered segments within one call. Sizing the buffer to one MSS -/// (1460 = 1500 MTU − 20 IP − 20 TCP) makes each `Response()` fill exactly and -/// complete, so a loop drains the whole body one segment per call. -const CHUNK: usize = 1460; - -/// Per-request timeout reported to the HTTP driver (milliseconds). -const HTTP_TIMEOUT_MS: u32 = 8_000; - -/// Hard wall-clock cap for a single request/response token, in 100ns units. -/// A bit longer than HTTP_TIMEOUT_MS so the driver's own timeout fires first; -/// this only guards against a driver that never completes the token at all. -const POLL_DEADLINE_100NS: u64 = 12 * 10_000_000; // 12 seconds - -/// An HTTP connection bound to one NIC, configured for IPv4 + DHCP. -pub struct HttpClient { - child: Handle, - binding: ScopedProtocol, - // `Option` so the protocol is dropped before we destroy the child handle. - http: Option>, +/// 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 HttpClient { - /// Find a NIC with the HTTP service binding, bring it up via DHCP, and - /// create a configured HTTP protocol instance on it. - pub fn new() -> Result { - // On a fresh boot the firmware often hasn't connected the network stack - // yet, so the HTTP service binding isn't present. Connect all drivers - // first, then locate the binding. - connect_all_controllers(); - - let nic = match boot::get_handle_for_protocol::() { - Ok(h) => h, - Err(e) => { - println!( - "stage0: no EFI_HTTP service binding found ({:?}) -- firmware lacks the HTTP/network stack?", - e.status() - ); - return Err(e.status()); - } - }; - println!("stage0: found HTTP service binding on NIC handle"); - - // Bring the interface up (DHCP). No-op if already up. - { - let mut ip4 = Ip4Config2::new(nic).map_err(|e| e.status())?; - ip4.ifup(true).map_err(|e| { - println!("stage0: DHCP failed: {:?}", e.status()); - e.status() - })?; - } - - let mut binding = unsafe { - boot::open_protocol::( - OpenProtocolParams { - handle: nic, - agent: boot::image_handle(), - controller: None, - }, - OpenProtocolAttributes::GetProtocol, - ) - .map_err(|e| e.status())? - }; - - let child = binding.create_child().map_err(|e| e.status())?; - - let mut http = unsafe { - boot::open_protocol::( - OpenProtocolParams { - handle: child, - agent: boot::image_handle(), - controller: None, - }, - OpenProtocolAttributes::GetProtocol, - ) - .map_err(|e| { - let _ = binding.destroy_child(child); - e.status() - })? - }; - - let ip4 = HttpV4AccessPoint { - use_default_addr: true.into(), - ..Default::default() - }; - let config = HttpConfigData { - http_version: HttpVersion::HTTP_VERSION_10, - time_out_millisec: HTTP_TIMEOUT_MS, - local_addr_is_ipv6: false.into(), - access_point: HttpAccessPoint { ipv4_node: &ip4 }, - }; - http.configure(&config).map_err(|e| { - println!("stage0: HTTP configure failed: {:?}", e.status()); - e.status() - })?; - println!("stage0: HTTP protocol configured"); - - Ok(Self { - child, - binding, - http: Some(http), - }) - } - - fn http(&mut self) -> &mut Http { - self.http.as_mut().unwrap() - } - - /// Send one HTTP request (does not read the response). - fn send_request( - &mut self, - method: HttpMethod, - url: &str, - headers: &[(&str, &str)], - ) -> Result<(), Status> { - println!("stage0: HTTP {:?} {}", method, url); - let url16 = CString16::try_from(url).map_err(|_| Status::INVALID_PARAMETER)?; - - // Backing storage for the header C strings; must outlive the request. - // Always send a Host header (servers reject requests without one) unless - // the caller already supplied one. - let mut cstrings: Vec<(CString, CString)> = Vec::with_capacity(headers.len() + 1); - let has_host = headers.iter().any(|(n, _)| n.eq_ignore_ascii_case("host")); - if !has_host { - if let Some(host) = host_from_url(url) { - cstrings.push(( - CString::new("Host").map_err(|_| Status::INVALID_PARAMETER)?, - CString::new(host).map_err(|_| Status::INVALID_PARAMETER)?, - )); - } - } - for (name, value) in headers { - cstrings.push(( - CString::new(*name).map_err(|_| Status::INVALID_PARAMETER)?, - CString::new(*value).map_err(|_| Status::INVALID_PARAMETER)?, - )); - } - let mut hdrs: Vec = cstrings - .iter() - .map(|(name, value)| HttpHeader { - field_name: name.as_ptr().cast::(), - field_value: value.as_ptr().cast::(), - }) - .collect(); - - let mut req = HttpRequestData { - method, - url: url16.as_ptr().cast::(), - }; - let mut tx_msg = HttpMessage::default(); - tx_msg.data.request = &mut req; - tx_msg.header_count = hdrs.len(); - tx_msg.header = hdrs.as_mut_ptr(); - - let event = make_wait_event()?; - let mut tx_token = HttpToken { - event: event.as_ptr(), - status: Status::NOT_READY, - message: &mut tx_msg, - }; - let res = self - .http() - .request(&mut tx_token) - .map_err(|e| { - println!("stage0: request() rejected: {:?}", e.status()); - e.status() - }) - .and_then(|()| self.await_completion(&tx_token, &event, "request")); - let _ = boot::close_event(unsafe { event.unsafe_clone() }); - res?; - if tx_token.status != Status::SUCCESS { - println!("stage0: request failed: {:?}", tx_token.status); - return Err(tx_token.status); - } - Ok(()) - } - - /// Read the first part of the response: status, headers (Content-Length), - /// and up to `cap` body bytes. Mirrors uefi-rs `HttpHelper::response_first`. - fn read_first( - &mut self, - cap: usize, - ) -> Result<(HttpStatusCode, Option, Vec), Status> { - let mut rsp = HttpResponseData { - status_code: HttpStatusCode::STATUS_UNSUPPORTED, - }; - let mut buf = vec![0u8; cap]; - let mut rx_msg = HttpMessage::default(); - rx_msg.data.response = &mut rsp; - rx_msg.body_length = buf.len(); - rx_msg.body = buf.as_mut_ptr().cast::(); - let event = make_wait_event()?; - let mut rx_token = HttpToken { - event: event.as_ptr(), - status: Status::NOT_READY, - message: &mut rx_msg, - }; - let res = self - .http() - .response(&mut rx_token) - .map_err(|e| e.status()) - .and_then(|()| self.await_completion(&rx_token, &event, "response")); - let _ = boot::close_event(unsafe { event.unsafe_clone() }); - res?; - // HTTP_ERROR means a response with a non-2xx status; still inspectable. - if rx_token.status != Status::SUCCESS && rx_token.status != Status::HTTP_ERROR { - println!("stage0: response failed: {:?}", rx_token.status); - return Err(rx_token.status); +impl HttpMethod { + fn as_str(self) -> &'static str { + match self { + HttpMethod::Get => "GET", + HttpMethod::Put => "PUT", } - let status_code = rsp.status_code; - let content_length = parse_content_length(&rx_msg); - let got = rx_msg.body_length; - println!( - "stage0: response {:?}, content-length={:?}, first {} B", - status_code, content_length, got - ); - Ok((status_code, content_length, buf[..got].to_vec())) - } - - /// Read up to `cap` more body bytes. Mirrors `HttpHelper::response_more`. - fn read_more(&mut self, cap: usize) -> Result, Status> { - let mut buf = vec![0u8; cap]; - let mut rx_msg = HttpMessage { - body_length: buf.len(), - body: buf.as_mut_ptr().cast::(), - ..Default::default() - }; - let event = make_wait_event()?; - let mut rx_token = HttpToken { - event: event.as_ptr(), - status: Status::NOT_READY, - message: &mut rx_msg, - }; - let res = self - .http() - .response(&mut rx_token) - .map_err(|e| e.status()) - .and_then(|()| self.await_completion(&rx_token, &event, "response-more")); - let _ = boot::close_event(unsafe { event.unsafe_clone() }); - res?; - if rx_token.status != Status::SUCCESS { - return Ok(Vec::new()); - } - Ok(buf[..rx_msg.body_length].to_vec()) - } - - /// Send a request and read the whole response body in 16 KiB chunks — the - /// multi-segment-safe pattern uefi-rs's HttpHelper uses. Returns (status, body). - pub fn fetch( - &mut self, - method: HttpMethod, - url: &str, - headers: &[(&str, &str)], - ) -> Result<(HttpStatusCode, Vec), Status> { - self.send_request(method, url, headers)?; - let (status, content_length, mut body) = self.read_first(CHUNK)?; - - // Pull the rest in chunks until we've read Content-Length bytes. We stop - // as soon as we have enough, so we never issue a Response() with nothing - // left to deliver (that would block until the timeout). - if let Some(total) = content_length { - while body.len() < total { - let chunk = self.read_more(CHUNK)?; - if chunk.is_empty() { - break; - } - body.extend_from_slice(&chunk); - println!("stage0: body {}/{} B", body.len(), total); - } - } - Ok((status, body)) - } - - /// Block until the async HTTP token completes, driven by the firmware's - /// event loop rather than a tight `poll()` spin. The token carries a wait - /// event that HttpDxe signals on completion; `WaitForEvent` lets the - /// network stack's timer/MNP events fire (which a busy poll loop can starve). - /// Bounded by a one-shot timer so a wedged driver can't hang the loader. - fn await_completion(&self, token: &HttpToken, event: &Event, what: &str) -> Result<(), Status> { - let timer = unsafe { boot::create_event(EventType::TIMER, Tpl::CALLBACK, None, None) } - .map_err(|e| e.status())?; - if let Err(e) = boot::set_timer(&timer, TimerTrigger::Relative(POLL_DEADLINE_100NS)) { - let _ = boot::close_event(timer); - return Err(e.status()); - } - - let result = loop { - if token.status != Status::NOT_READY { - break Ok(()); - } - let mut events = [unsafe { event.unsafe_clone() }, unsafe { - timer.unsafe_clone() - }]; - match boot::wait_for_event(&mut events) { - Ok(0) => {} // completion event signaled; re-check token.status - Ok(_) => { - println!("stage0: [{what}] TIMEOUT (event wait)"); - break Err(Status::TIMEOUT); - } - Err(e) => break Err(e.status()), - } - }; - - let _ = boot::set_timer(&timer, TimerTrigger::Cancel); - let _ = boot::close_event(timer); - result } } -/// Create a plain, waitable event for an async HTTP token (no notify function, -/// so it can be passed to `WaitForEvent`; HttpDxe signals it on completion). -fn make_wait_event() -> Result { - unsafe { boot::create_event(EventType::empty(), Tpl::CALLBACK, None, None) } - .map_err(|e| e.status()) +/// `true` for a 2xx status code. +#[must_use] +pub fn is_ok(status: u16) -> bool { + (200..300).contains(&status) } -/// Connect all drivers to all handles (best-effort), forcing the firmware to -/// bind its network stack so the HTTP service binding becomes 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) => { - println!("stage0: locate_handle_buffer failed: {:?}", e.status()); - return; - } +/// 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 connected = 0usize; - for handle in handles.iter() { - if boot::connect_controller(*handle, None, None, true).is_ok() { - connected += 1; - } + + 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"); } - println!( - "stage0: connected drivers on {}/{} handles", - connected, - handles.len() - ); + 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)) } -impl Drop for HttpClient { - fn drop(&mut self) { - // Protocol must be closed before the child handle is destroyed. - self.http = None; - let _ = self.binding.destroy_child(self.child); +/// 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) } -/// `true` if the status code is 200 OK. -#[must_use] -pub fn is_ok(status: HttpStatusCode) -> bool { - status == HttpStatusCode::STATUS_200_OK +/// 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() } -/// Extract the authority (`host[:port]`) from an `http://host/...` URL. -fn host_from_url(url: &str) -> Option<&str> { - // "http://HOST/path" -> split on '/' -> ["http:", "", "HOST", "path", ...] - url.split('/').nth(2).filter(|h| !h.is_empty()) +/// 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)) } -/// Parse the `Content-Length` response header, if present. -fn parse_content_length(msg: &HttpMessage) -> Option { - for i in 0..msg.header_count { - unsafe { - let h = &*msg.header.add(i); - let name = CStr::from_ptr(h.field_name.cast::()) - .to_str() - .ok()?; - if name.eq_ignore_ascii_case("content-length") { - let value = CStr::from_ptr(h.field_value.cast::()) - .to_str() - .ok()?; - return value.trim().parse::().ok(); - } - } +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; } - None + Some(octets) } -/// Collect the response headers as lowercased name/value pairs (unused by the -/// happy path but handy for diagnostics). -#[allow(dead_code)] -fn collect_headers(msg: &HttpMessage) -> Vec<(String, String)> { - let mut headers = Vec::new(); - for i in 0..msg.header_count { - unsafe { - let h = &*msg.header.add(i); - let name = CStr::from_ptr(h.field_name.cast::()); - let value = CStr::from_ptr(h.field_value.cast::()); - if let (Ok(n), Ok(v)) = (name.to_str(), value.to_str()) { - headers.push((n.to_lowercase(), String::from(v))); - } - } - } - headers +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 index 389a1de..3fbe237 100644 --- a/crates/stage0/src/main.rs +++ b/crates/stage0/src/main.rs @@ -1,19 +1,19 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 -//! stage0 — a measured UEFI network bootloader for the lockboot stack. +//! stage0 - a measured UEFI network bootloader for the lockboot stack. //! -//! Boots as a pure UEFI application (no Linux kernel), pulls a `_stage0` +//! 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 -//! (`sig.rs`) — measures it into the TPM via `EFI_TCG2_PROTOCOL` (PCR 14 = +//! 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 — "stage0 ran, and it -//! loaded a binary with this hash." The admission signature/key are not measured. +//! 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] @@ -22,12 +22,15 @@ 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; @@ -35,7 +38,7 @@ use sha2::{Digest, Sha256}; use uefi::boot; use uefi::prelude::*; use uefi::proto::loaded_image::LoadedImage; -use uefi::{println, CString16}; +use uefi::CString16; /// PCR extended with SHA-256 of the loaded payload (matches stage1's binary PCR). const PCR_BINARY: u8 = 14; @@ -45,11 +48,11 @@ fn main() -> Status { uefi::helpers::init().unwrap(); match run() { Ok(()) => { - println!("stage0: payload returned control to stage0 (unexpected)"); + crate::slog!("stage0: payload returned control to stage0 (unexpected)"); Status::LOAD_ERROR } Err(status) => { - println!("stage0: ERROR {:?}", status); + crate::slog!("stage0: ERROR {:?}", status); // Pause so the failure is visible on the serial console. boot::stall(5_000_000); status @@ -58,60 +61,78 @@ fn main() -> Status { } fn run() -> Result<(), Status> { - println!("stage0: measured UEFI netboot starting"); + // Calibrate the boot-relative clock first so every log line below is stamped. + timing::init(); + crate::slog!("stage0: version: {}", env!("CARGO_PKG_VERSION")); - // Fetch metadata on its own HTTP instance, then drop it. Small metadata - // bodies download fine over EFI_HTTP; the payload uses raw TCP4 below. + // 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) = { - let mut client = http::HttpClient::new()?; - println!("stage0: network configured"); - - let json = metadata::fetch(&mut client)?; - println!("stage0: fetched {} bytes of user-data", json.len()); - + 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| { - println!("stage0: config error: {m}"); + crate::slog!("stage0: config error: {m}"); Status::INVALID_PARAMETER })?; - let arch = user_data.stage0.for_this_arch().ok_or_else(|| { - println!("stage0: no _stage0 config for this architecture"); + 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| { - println!("stage0: invalid arch config: {m}"); + crate::slog!("stage0: invalid arch config: {m}"); Status::INVALID_PARAMETER })?; - (arch.url.clone(), verify, user_data.stage0.args.clone()) + (arch.url.clone(), verify, user_data.stage1.args.clone()) }; - // The payload is downloaded over raw TCP4 (EFI_HTTP/HttpDxe won't drain a - // multi-segment body here; see tcp4.rs). Metadata stays on EFI_HTTP above. - println!("stage0: downloading payload from {url}"); - let binary = tcp4::download(&url)?; - println!("stage0: downloaded {} bytes", binary.len()); + // 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) => { - let actual = hex::encode(digest); - if !actual.eq_ignore_ascii_case(expected) { - println!("stage0: SHA256 mismatch! expected {expected}, got {actual}"); + if !hash.eq_ignore_ascii_case(expected) { + crate::slog!("stage0: SHA256 mismatch! expected {expected}, got {hash}"); return Err(Status::SECURITY_VIOLATION); } - println!("stage0: SHA256 verified"); + crate::slog!("stage0: verified: sha256:{hash} (sha256 pin)"); } - Verify::Ed25519(pubkey) => { - // Detached signature lives alongside the payload at .sig. - let sig_url = alloc::format!("{url}.sig"); - println!("stage0: fetching signature from {sig_url}"); - let signature = tcp4::download(&sig_url)?; + 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| { - println!("stage0: ed25519 verification failed: {m}"); + crate::slog!("stage0: ed25519 verification failed: {m}"); Status::SECURITY_VIOLATION })?; - println!("stage0: ed25519 signature verified"); + 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)?); + } } } @@ -121,25 +142,28 @@ fn run() -> Result<(), Status> { // it exclusively, and the payload needs to open it too (else ACCESS_DENIED). { let mut tpm = tcg2::open_tpm().map_err(|e| { - println!("stage0: TPM unavailable: {e}"); + crate::slog!("stage0: TPM unavailable: {e}"); Status::DEVICE_ERROR })?; measure(&mut tpm, PCR_BINARY, &digest)?; } - println!("stage0: extended PCR{PCR_BINARY} with the payload measurement"); + 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| { - println!("stage0: load_image failed: {status:?}"); + crate::slog!("stage0: load_image failed: {status:?}"); })?; - // Optionally pass args as UEFI load options; the backing buffer must stay - // alive until after start_image. - let _options = set_load_options(image, args.as_deref()); + // 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()); - println!("stage0: starting payload"); + crate::slog!("stage0: starting payload"); boot::start_image(image).map_err(|e| e.status())?; Ok(()) @@ -149,19 +173,53 @@ fn run() -> Result<(), Status> { 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| { - println!("stage0: pcr_extend(PCR{pcr}) failed: {e}"); + crate::slog!("stage0: pcr_extend(PCR{pcr}) failed: {e}"); Status::DEVICE_ERROR }) } -/// Set the loaded image's load options from `args` (UCS-2). Returns the backing -/// [`CString16`], which the caller must keep alive until `start_image`. -fn set_load_options(image: Handle, args: Option<&[String]>) -> Option { - let args = args?; - if args.is_empty() { +/// 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(args.join(" ").as_str()).ok()?; + 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); diff --git a/crates/stage0/src/metadata.rs b/crates/stage0/src/metadata.rs index 508b194..53b1e11 100644 --- a/crates/stage0/src/metadata.rs +++ b/crates/stage0/src/metadata.rs @@ -4,15 +4,17 @@ //! //! 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. +//! 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::{is_ok, HttpClient, HttpMethod}; -use uefi::{println, Status}; +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"; @@ -23,10 +25,10 @@ const AZURE_USERDATA_URL: &str = 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(&mut HttpClient) -> Result, Status>; +type Provider = fn() -> Result, Status>; /// Try each cloud provider in turn; return the first user-data document found. -pub fn fetch(client: &mut HttpClient) -> Result, Status> { +pub fn fetch() -> Result, Status> { let providers: [(&str, Provider); 4] = [ ("EC2 (IMDSv2)", try_ec2), ("GCP", try_gcp), @@ -34,23 +36,24 @@ pub fn fetch(client: &mut HttpClient) -> Result, Status> { ("Aliyun", try_aliyun), ]; for (name, try_fn) in providers { - println!("stage0: trying metadata provider: {name}"); - match try_fn(client) { + crate::sdbg!("stage0: trying metadata provider: {name}"); + match try_fn() { Ok(data) => { - println!("stage0: {name} returned {} bytes", data.len()); + let h = hex::encode(Sha256::digest(&data)); + crate::slog!("stage0: metadata: {name} {} bytes sha256:{h}", data.len()); return Ok(data); } - Err(e) => println!("stage0: {name} failed: {:?}", e), + Err(e) => crate::sdbg!("stage0: {name} failed: {:?}", e), } } - println!("stage0: no metadata provider responded"); + 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(client: &mut HttpClient) -> Result, Status> { - let (status, token) = client.fetch( - HttpMethod::PUT, +fn try_ec2() -> Result, Status> { + let (status, token) = http::fetch( + HttpMethod::Put, EC2_TOKEN_URL, &[("X-aws-ec2-metadata-token-ttl-seconds", "21600")], )?; @@ -60,8 +63,8 @@ fn try_ec2(client: &mut HttpClient) -> Result, Status> { let token = String::from_utf8(token).map_err(|_| Status::ABORTED)?; let token = token.trim(); - let (status, body) = client.fetch( - HttpMethod::GET, + let (status, body) = http::fetch( + HttpMethod::Get, EC2_USERDATA_URL, &[("X-aws-ec2-metadata-token", token)], )?; @@ -72,9 +75,9 @@ fn try_ec2(client: &mut HttpClient) -> Result, Status> { } /// GCP compute metadata (reachable at the link-local IP; requires the flavor header). -fn try_gcp(client: &mut HttpClient) -> Result, Status> { - let (status, body) = client.fetch( - HttpMethod::GET, +fn try_gcp() -> Result, Status> { + let (status, body) = http::fetch( + HttpMethod::Get, GCP_USERDATA_URL, &[ ("Metadata-Flavor", "Google"), @@ -88,9 +91,8 @@ fn try_gcp(client: &mut HttpClient) -> Result, Status> { } /// Azure IMDS: user-data is returned base64-encoded. -fn try_azure(client: &mut HttpClient) -> Result, Status> { - let (status, body) = - client.fetch(HttpMethod::GET, AZURE_USERDATA_URL, &[("Metadata", "true")])?; +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); } @@ -99,8 +101,8 @@ fn try_azure(client: &mut HttpClient) -> Result, Status> { } /// Aliyun ECS metadata (best-effort; v1 plain GET of user-data). -fn try_aliyun(client: &mut HttpClient) -> Result, Status> { - let (status, body) = client.fetch(HttpMethod::GET, ALIYUN_USERDATA_URL, &[])?; +fn try_aliyun() -> Result, Status> { + let (status, body) = http::fetch(HttpMethod::Get, ALIYUN_USERDATA_URL, &[])?; if !is_ok(status) { return Err(Status::ABORTED); } diff --git a/crates/stage0/src/net.rs b/crates/stage0/src/net.rs new file mode 100644 index 0000000..104a3ee --- /dev/null +++ b/crates/stage0/src/net.rs @@ -0,0 +1,90 @@ +// 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 index 087ac2e..e5e283b 100644 --- a/crates/stage0/src/secauth.rs +++ b/crates/stage0/src/secauth.rs @@ -3,22 +3,22 @@ //! 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 +//! 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 +//! 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 +//! 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. @@ -96,7 +96,7 @@ struct AuthOverride { impl AuthOverride { fn install() -> Self { - // EFI_SECURITY2_ARCH_PROTOCOL — authoritative for buffer loads on all + // 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; @@ -105,7 +105,7 @@ impl AuthOverride { (sp, saved) }); - // EFI_SECURITY_ARCH_PROTOCOL — only consulted when Security2 is absent, + // 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; @@ -153,7 +153,7 @@ fn open() -> Option /// `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. +/// 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( diff --git a/crates/stage0/src/sig.rs b/crates/stage0/src/sig.rs index 39a80ec..b9a1de6 100644 --- a/crates/stage0/src/sig.rs +++ b/crates/stage0/src/sig.rs @@ -8,7 +8,7 @@ //! (`.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 +//! 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. diff --git a/crates/stage0/src/tcg2.rs b/crates/stage0/src/tcg2.rs index a633c2c..730bd28 100644 --- a/crates/stage0/src/tcg2.rs +++ b/crates/stage0/src/tcg2.rs @@ -6,7 +6,7 @@ //! [`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. +//! unchanged here, keeping a single measurement (and verification) model. use alloc::boxed::Box; use alloc::vec; diff --git a/crates/stage0/src/tcp4.rs b/crates/stage0/src/tcp4.rs index ed4a34c..acd3560 100644 --- a/crates/stage0/src/tcp4.rs +++ b/crates/stage0/src/tcp4.rs @@ -1,17 +1,17 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 -//! Minimal HTTP/1.0 client over `EFI_TCP4_PROTOCOL`. +//! 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. //! -//! OVMF/EDK2's `EFI_HTTP_PROTOCOL` (HttpDxe) will not deliver a multi-segment -//! response body in our usage: packet captures show the whole body arrives and -//! is ACKed by the firmware's TCP stack, but HttpDxe pulls only the first -//! segment and never drains the rest of its receive buffer. (Hardened firmware -//! such as AWS Nitro may additionally refuse plain `http://` via EFI_HTTP when -//! `PcdAllowHttpConnections=FALSE`.) So the payload download drops one layer to -//! raw TCP4 and runs its own `Receive()` loop — the exact step HttpDxe skips. +//! 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 (straight from the -//! UEFI spec, EFI_TCP4_PROTOCOL) are defined here. +//! `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; @@ -20,7 +20,7 @@ use core::ptr; use uefi::boot::{self, OpenProtocolAttributes, OpenProtocolParams}; use uefi::proto::unsafe_protocol; -use uefi::{println, Status}; +use uefi::Status; use uefi_raw::protocol::driver::ServiceBindingProtocol; use uefi_raw::{Boolean, Event, Ipv4Address}; @@ -113,19 +113,20 @@ struct Tcp4Sb(ServiceBindingProtocol); #[unsafe_protocol("65530bc7-a359-410f-b010-5aadc7ec2b62")] struct Tcp4(Tcp4Protocol); -/// Drive an async token to completion by polling its volatile `status`, pumping -/// the driver via `Poll()` and stalling 1ms between checks. Bounded by `budget_ms`. -unsafe fn pump(tcp: *mut Tcp4Protocol, status: *const Status, budget_ms: u32) -> Status { - let mut waited = 0; +/// 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); - boot::stall(1000); - waited += 1; - if waited >= budget_ms { + if crate::timing::since_boot_ms().wrapping_sub(start) >= budget_ms { return Status::TIMEOUT; } } @@ -146,9 +147,10 @@ fn new_event() -> Result { /// Connect to `ip:port`, send `request`, and read the full response until the /// peer closes the connection (so requests must ask for `Connection: close`). -fn exchange(ip: [u8; 4], port: u16, request: &[u8]) -> Result, Status> { +/// 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| { - println!("stage0: no EFI_TCP4 service binding: {:?}", e.status()); + crate::slog!("stage0: no EFI_TCP4 service binding: {:?}", e.status()); e.status() })?; let mut sb = unsafe { @@ -213,11 +215,10 @@ fn exchange_on_child( }; let st = unsafe { ((*tcp_ptr).configure)(tcp_ptr, &cfg) }; if st != Status::SUCCESS { - println!("stage0: TCP4 configure failed: {st:?}"); + crate::slog!("stage0: TCP4 configure failed: {st:?}"); return Err(st); } - // Connect. let event = new_event()?; let mut ct = ConnectionToken { completion_token: CompletionToken { @@ -233,11 +234,11 @@ fn exchange_on_child( }; let _ = unsafe { uefi::Event::from_ptr(event).map(boot::close_event) }; if st != Status::SUCCESS { - println!("stage0: TCP4 connect failed: {st:?}"); + crate::slog!("stage0: TCP4 connect failed: {st:?}"); let _ = unsafe { ((*tcp_ptr).configure)(tcp_ptr, ptr::null()) }; return Err(st); } - println!( + crate::sdbg!( "stage0: TCP4 connected to {}.{}.{}.{}:{}", ip[0], ip[1], ip[2], ip[3], port ); @@ -277,7 +278,7 @@ fn tcp_send(tcp_ptr: *mut Tcp4Protocol, data: &[u8]) -> Result<(), Status> { }; let _ = unsafe { uefi::Event::from_ptr(event).map(boot::close_event) }; if st != Status::SUCCESS { - println!("stage0: TCP4 transmit failed: {st:?}"); + crate::slog!("stage0: TCP4 transmit failed: {st:?}"); return Err(st); } Ok(()) @@ -319,92 +320,11 @@ fn tcp_recv_all(tcp_ptr: *mut Tcp4Protocol) -> Result, Status> { } out.extend_from_slice(&buf[..got]); } else { - // EFI_CONNECTION_FIN / reset / timeout — peer closed or done. + // 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) } - -/// Download `url` over raw TCP4 and return the response body. The host may be an -/// IPv4 literal or a name resolved over EFI_DNS4. Uses `Connection: close` so the -/// body is delimited by the peer closing. -pub fn download(url: &str) -> Result, Status> { - let (host, port, path) = parse_http_url(url).ok_or_else(|| { - println!("stage0: TCP4 download: unsupported URL (need http://host[:port]/path): {url}"); - Status::INVALID_PARAMETER - })?; - - // IPv4 literal connects directly; a hostname is resolved over EFI_DNS4. - let ip = match parse_ipv4(host) { - Some(ip) => ip, - None => crate::dns4::resolve(host)?, - }; - - let mut req = alloc::string::String::new(); - req.push_str("GET "); - req.push_str(path); - req.push_str(" HTTP/1.1\r\nHost: "); - req.push_str(host); - req.push_str("\r\nConnection: close\r\nUser-Agent: stage0\r\n\r\n"); - println!("stage0: TCP4 GET {url}"); - - let raw = exchange(ip, port, req.as_bytes())?; - println!("stage0: TCP4 received {} bytes total", raw.len()); - - // Split headers/body on the blank line. - let sep = find_subslice(&raw, b"\r\n\r\n").ok_or_else(|| { - println!("stage0: TCP4 response had no header terminator"); - Status::PROTOCOL_ERROR - })?; - let head = &raw[..sep]; - let body = raw[sep + 4..].to_vec(); - - // Status line: "HTTP/1.x NNN ..." - let status_ok = head - .split(|&b| b == b'\n') - .next() - .map(|line| find_subslice(line, b" 200 ").is_some() || line.ends_with(b" 200")) - .unwrap_or(false); - if !status_ok { - let line = core::str::from_utf8(head.split(|&b| b == b'\n').next().unwrap_or(b"")) - .unwrap_or(""); - println!("stage0: TCP4 non-200 status: {}", line.trim_end()); - return Err(Status::ABORTED); - } - Ok(body) -} - -/// 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/timing.rs b/crates/stage0/src/timing.rs new file mode 100644 index 0000000..45be359 --- /dev/null +++ b/crates/stage0/src/timing.rs @@ -0,0 +1,117 @@ +// 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 index fcc62f2..016a828 100755 --- a/tools/build-stage0/build.sh +++ b/tools/build-stage0/build.sh @@ -107,6 +107,26 @@ else echo "Warning: ${OVMF_VARS_ORIG} not found; skipping EFI vars generation." fi +# Emit os-release so the cloud publishers can name/tag the image (mirrors +# build-uki). BUILD_ID is derived from the signed stage0 hash so each build is +# traceable and distinct from UKI AMIs. +OSREL_PATH="${OUTPUT_DIR}/os-release" +YEAR_MONTH=$(date +%y.%m) +{ + echo "ID=lockboot" + echo "VERSION_ID=${YEAR_MONTH}" + echo "NAME=\"Lock.Boot stage0\"" + echo "PRETTY_NAME=\"Lock.Boot stage0 ${YEAR_MONTH} ${ARCH}\"" + echo "BUILD_ID=stage0-${EFI_HASH:0:12}" +} > "${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/.gitignore b/tools/build-uki/.gitignore index 921e020..5c649e7 100644 --- a/tools/build-uki/.gitignore +++ b/tools/build-uki/.gitignore @@ -3,3 +3,5 @@ x86_64 aarch64 *-linux-* +# mkuki host build tool (built by `make tools/build-uki/mkuki`) +/mkuki diff --git a/tools/build-uki/build.sh b/tools/build-uki/build.sh index fc4c15b..7cdf3a9 100755 --- a/tools/build-uki/build.sh +++ b/tools/build-uki/build.sh @@ -4,9 +4,6 @@ set -euo pipefail # Get the absolute path of the script directory SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# Setup secure boot keys (relative to script directory) -KEYDIR="${SCRIPT_DIR}/keys" - # Get architecture from environment (default to x86_64) ARCH=${ARCH:-x86_64} @@ -16,14 +13,9 @@ echo "=== Building UKI for Fedora 41 (${ARCH}) ===" OUTPUT_DIR="${SCRIPT_DIR}/${ARCH}" mkdir -p "${OUTPUT_DIR}/tmp" -# Determine the correct systemd-efistub filename, PE format, and objcopy command based on architecture -if [ "${ARCH}" = "x86_64" ]; then - PE_FORMAT="pei-x86-64" - OBJCOPY="x86_64-linux-gnu-objcopy" -elif [ "${ARCH}" = "aarch64" ]; then - PE_FORMAT="pei-aarch64-little" - OBJCOPY="aarch64-linux-gnu-objcopy" -else +# Validate the target architecture (mkuki handles PE assembly in-process, so no +# arch-specific objcopy/PE-format selection is needed here anymore). +if [ "${ARCH}" != "x86_64" ] && [ "${ARCH}" != "aarch64" ]; then echo "ERROR: Unsupported architecture: ${ARCH}" exit 1 fi @@ -72,27 +64,30 @@ if [ -f "${KERNEL_CONFIG_PATH}" ]; then echo "" fi -# Build minimal initramfs -echo "Building minimal initramfs..." -INITRD_PATH="${OUTPUT_DIR}/initrd-${KERNEL_VERSION}.img" -INITRAMFS_DIR="${OUTPUT_DIR}/tmp/initramfs-$$" +# Build the layered initramfs as two reproducible layers that mkuki concatenates +# into one .initrd (the kernel unpacks concatenated cpios as one rootfs): +# platform = kernel modules + depmod metadata (version-locked to the kernel) +# userland = busybox + init + udhcpc + stage1 (kernel-agnostic) +echo "Staging initramfs layers..." +PLATFORM_DIR="${OUTPUT_DIR}/tmp/layer-platform-$$" +USERLAND_DIR="${OUTPUT_DIR}/tmp/layer-userland-$$" -# Create directory structure -mkdir -p "${INITRAMFS_DIR}"/{bin,sbin,etc,proc,sys,dev,lib,lib64,tmp} - -# Copy busybox and create symlinks +# --- userland layer --- +mkdir -p "${USERLAND_DIR}"/{bin,sbin,etc,proc,sys,dev,lib,lib64,tmp} echo "Installing busybox..." -cp "${OUTPUT_DIR}/busybox" "${INITRAMFS_DIR}/bin/" - -# Copy stage1 binary +cp "${OUTPUT_DIR}/busybox" "${USERLAND_DIR}/bin/" echo "Installing stage1..." -cp "${OUTPUT_DIR}/stage1" "${INITRAMFS_DIR}/bin/stage1" -chmod +x "${INITRAMFS_DIR}/bin/stage1" - -# Copy required kernel modules +cp "${OUTPUT_DIR}/stage1" "${USERLAND_DIR}/bin/stage1" +chmod +x "${USERLAND_DIR}/bin/stage1" +cp "${SCRIPT_DIR}/init" "${USERLAND_DIR}/init" +chmod +x "${USERLAND_DIR}/init" +cp "${SCRIPT_DIR}/udhcpc.script" "${USERLAND_DIR}/bin/udhcpc.script" +chmod +x "${USERLAND_DIR}/bin/udhcpc.script" + +# --- platform layer (kernel modules) --- echo "Copying kernel modules..." MODULES_SRC="${OUTPUT_DIR}/tmp/lib/modules/${KERNEL_VERSION}" -MODULES_DST="${INITRAMFS_DIR}/lib/modules/${KERNEL_VERSION}" +MODULES_DST="${PLATFORM_DIR}/lib/modules/${KERNEL_VERSION}" mkdir -p "${MODULES_DST}/kernel/drivers" # Copy required modules for cloud instances (AWS EC2, GCP Confidential VMs) @@ -152,27 +147,13 @@ for modfile in modules.order modules.builtin modules.builtin.modinfo; do fi done -# Run depmod to generate modules.dep -depmod -b "${INITRAMFS_DIR}" "${KERNEL_VERSION}" - -# Copy init script -cp "${SCRIPT_DIR}/init" "${INITRAMFS_DIR}/init" -chmod +x "${INITRAMFS_DIR}/init" - -cp "${SCRIPT_DIR}/udhcpc.script" "${INITRAMFS_DIR}/bin/udhcpc.script" -chmod +x "${INITRAMFS_DIR}/bin/udhcpc.script" - -# Create the initramfs archive (reproducible build) -echo "Creating initramfs archive..." -# Set all file timestamps to epoch for reproducibility (do this LAST after all file operations) -find "${INITRAMFS_DIR}" -exec touch -h -t 197001010000 {} + -# Create reproducible cpio archive with sorted file list and no timestamps in gzip -(cd "${INITRAMFS_DIR}" && find . -print0 | LC_ALL=C sort -z | cpio -o -H newc -0 --reproducible 2>/dev/null || cpio -o -H newc -0) | gzip -n > "${INITRD_PATH}" - -# Cleanup -rm -rf "${INITRAMFS_DIR}" +# Run depmod to generate modules.dep inside the platform layer. +depmod -b "${PLATFORM_DIR}" "${KERNEL_VERSION}" -echo "Initrd created: ${INITRD_PATH}" +# init + udhcpc.script were staged into the userland layer above. mkuki builds one +# reproducible gzipped cpio per layer (zeroed uid/gid/mtime, sorted) and concatenates +# them into .initrd, so no manual cpio/gzip/touch step is needed here. +echo "Initramfs layers staged: platform + userland" # Get kernel image path KERNEL_PATH="${OUTPUT_DIR}/tmp/lib/modules/${KERNEL_VERSION}/vmlinuz" @@ -232,9 +213,6 @@ else fi echo "${CMDLINE_SERIAL} ${CMDLINE_COMMON}" > "${CMDLINE_PATH}" -UNAME_PATH="${OUTPUT_DIR}/uname.txt" -echo "${KERNEL_VERSION}" > "${UNAME_PATH}" - # Set SOURCE_DATE_EPOCH for reproducible builds export SOURCE_DATE_EPOCH=0 @@ -251,212 +229,60 @@ echo "VERSION=\"${OSREL_VERSION}\"" >> "${OSREL_PATH}" echo "NAME=\"${OSREL_NAME}\"" >> "${OSREL_PATH}" echo "PRETTY_NAME=\"${OSREL_NAME} ${OSREL_VERSION}\"" >> "${OSREL_PATH}" -# Calculate BUILD_ID from component hashes -INITRD_HASH=$(sha256sum "${INITRD_PATH}" | cut -d' ' -f1 | cut -c1-8) +# BUILD_ID from kernel + cmdline. The authoritative per-layer and full .initrd +# sha256 are printed by mkuki below (it owns the cpio layer assembly now). KERNEL_HASH=$(sha256sum "${KERNEL_PATH}" | cut -d' ' -f1 | cut -c1-8) CMDLINE_HASH=$(sha256sum "${CMDLINE_PATH}" | cut -d' ' -f1 | cut -c1-8) -BUILD_ID="kernel-${KERNEL_VERSION}-${KERNEL_HASH}.cmdline-${CMDLINE_HASH}.initrd-${INITRD_HASH}" +BUILD_ID="kernel-${KERNEL_VERSION}-${KERNEL_HASH}.cmdline-${CMDLINE_HASH}" echo "BUILD_ID=${BUILD_ID}" >> "${OSREL_PATH}" -# Calculate section VMAs dynamically based on the stub's layout. -# Newer systemd stubs (v256+) use high VMAs that differ from v252's layout, -# so we append our sections after the stub's last section (same approach as ukify). -SECTION_ALIGN=0x1000 -NEXT_VMA=$(${OBJCOPY%%objcopy}objdump -h "${STUB_PATH}" | \ - awk '/^ [0-9]/ { print $3, $4 }' | \ - while read size_hex vma_hex; do - echo $(( 0x${vma_hex} + 0x${size_hex} )) - done | sort -n | tail -1) -NEXT_VMA=$(( (NEXT_VMA + SECTION_ALIGN - 1) / SECTION_ALIGN * SECTION_ALIGN )) - -# Place each section sequentially, aligned to SECTION_ALIGN -calc_next_vma() { - local current_vma=$1 - local file=$2 - local size - size=$(stat -c%s "$file") - echo $(( (current_vma + size + SECTION_ALIGN - 1) / SECTION_ALIGN * SECTION_ALIGN )) -} - -OSREL_VMA=${NEXT_VMA} -CMDLINE_VMA=$(calc_next_vma ${OSREL_VMA} "${OSREL_PATH}") -UNAME_VMA=$(calc_next_vma ${CMDLINE_VMA} "${CMDLINE_PATH}") -LINUX_VMA=$(calc_next_vma ${UNAME_VMA} "${UNAME_PATH}") -INITRD_VMA=$(calc_next_vma ${LINUX_VMA} "${KERNEL_PATH}") - -echo "UKI section layout:" -printf " .osrel @ 0x%x\n" ${OSREL_VMA} -printf " .cmdline @ 0x%x\n" ${CMDLINE_VMA} -printf " .uname @ 0x%x\n" ${UNAME_VMA} -printf " .linux @ 0x%x\n" ${LINUX_VMA} -printf " .initrd @ 0x%x\n" ${INITRD_VMA} - -${OBJCOPY} \ - --input-target="${PE_FORMAT}" \ - --output-target="${PE_FORMAT}" \ - --add-section .osrel="${OSREL_PATH}" --change-section-vma .osrel=${OSREL_VMA} \ - --add-section .cmdline="${CMDLINE_PATH}" --change-section-vma .cmdline=${CMDLINE_VMA} \ - --add-section .uname="${UNAME_PATH}" --change-section-vma .uname=${UNAME_VMA} \ - --add-section .linux="${KERNEL_PATH}" --change-section-vma .linux=${LINUX_VMA} \ - --add-section .initrd="${INITRD_PATH}" --change-section-vma .initrd=${INITRD_VMA} \ - "${STUB_PATH}" "${UKI_PATH}" - -sbsign --key "$KEYDIR/db.crt.key" --cert "$KEYDIR/db.crt" --output "${UKI_PATH}" "${UKI_PATH}" - +# Assemble the UKI with mkuki (no binutils/objcopy/objdump): it builds one +# reproducible gzipped cpio per --layer, concatenates them into .initrd, computes +# the section VMAs from the stub, and grafts the PE sections in-process. Layer +# order is significant: platform first (provides /lib/modules), then userland +# (provides /init and /bin/*; later layers overlay earlier ones). +MKUKI="${SCRIPT_DIR}/mkuki" +"${MKUKI}" \ + --layer "${PLATFORM_DIR}" \ + --layer "${USERLAND_DIR}" \ + --kernel "${KERNEL_PATH}" \ + --stub "${STUB_PATH}" \ + --cmdline "$(cat "${CMDLINE_PATH}")" \ + --uname "${KERNEL_VERSION}" \ + --os-release "${OSREL_PATH}" \ + --out "${UKI_PATH}" \ + --arch "${ARCH}" + +# Drop the staged layer trees now that they are baked into the UKI. +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 +# 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, +# so mkuki's output is shipped verbatim with no sbsign step. echo "UKI created: ${UKI_PATH}" ls -lh "${UKI_PATH}" # Copy kernel separately for reference cp "${KERNEL_PATH}" "${OUTPUT_DIR}/vmlinuz-${KERNEL_VERSION}" -echo "" -echo "=== Creating bootable disk image ===" - -# Calculate required disk size based on UKI file size -# Add overhead for: GPT headers (1MB front + 1MB back), FAT32 overhead (~10%), alignment, and safety margin -if [ -f "${UKI_PATH}" ]; then - UKI_SIZE_BYTES=$(stat -c%s "${UKI_PATH}") - UKI_SIZE_MB=$((UKI_SIZE_BYTES / 1024 / 1024 + 1)) - # Calculate total size: 1MB (front GPT) + partition size + 1MB (back GPT) - # Partition size = UKI size * 1.5 (50% overhead for FAT32, alignment, and safety) - PARTITION_SIZE_MB=$((UKI_SIZE_MB * 3 / 2)) - # Ensure minimum partition size of 33MB (FAT32 minimum) - if [ ${PARTITION_SIZE_MB} -lt 100 ]; then - PARTITION_SIZE_MB=100 - fi - DISK_SIZE_MB=$((PARTITION_SIZE_MB + 2)) - echo "UKI size: ${UKI_SIZE_MB}MB" - echo "Partition size: ${PARTITION_SIZE_MB}MB" - echo "Total disk size: ${DISK_SIZE_MB}MB" -else - echo "Error: UKI file not found at ${UKI_PATH}" - exit 1 -fi - -DISK_IMAGE="${OUTPUT_DIR}/boot.disk" - -echo "Creating ${DISK_SIZE_MB}MB disk image..." -dd if=/dev/zero of="${DISK_IMAGE}" bs=1M count=${DISK_SIZE_MB} status=progress - -# Generate deterministic GUIDs from UKI hash for reproducible builds -echo "Generating deterministic GUIDs from UKI content..." -UKI_HASH=$(sha256sum "${UKI_PATH}" | cut -d' ' -f1) -# Create disk GUID from first 32 hex chars of hash -DISK_GUID="${UKI_HASH:0:8}-${UKI_HASH:8:4}-${UKI_HASH:12:4}-${UKI_HASH:16:4}-${UKI_HASH:20:12}" -# Create partition GUID from next 32 hex chars -PART_GUID="${UKI_HASH:32:8}-${UKI_HASH:36:4}-${UKI_HASH:40:4}-${UKI_HASH:44:4}-${UKI_HASH:48:12}" -echo "Disk GUID: ${DISK_GUID}" -echo "Partition GUID: ${PART_GUID}" - -# Create GPT partition table with deterministic GUIDs using sfdisk -echo "Creating reproducible GPT partition table..." -sfdisk "${DISK_IMAGE}" < "${OUTPUT_DIR}/linux.efi.sha256" + +# Ready-to-paste _stage1 user-data snippet (sha256 admission mode). Deployers who +# want signed-mode rollforward re-sign linux.efi with their own ed25519 key. +cat > "${OUTPUT_DIR}/stage0-snippet.json" < [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 + +set -euo pipefail + +if [ $# -lt 2 ]; then + echo "Usage: $0 [version]" + exit 1 +fi + +DEST_URI="${1%/}" # strip trailing slash +ARCH="$2" +VERSION="${3:-local}" + +if [ "${ARCH}" != "x86_64" ] && [ "${ARCH}" != "aarch64" ]; then + echo "Error: arch must be x86_64 or aarch64"; exit 1 +fi + +# Resolve the UKI: local build, or a verified uki-v* release artifact. +if [ "${VERSION}" != "local" ]; then + TEMP_DIR=$(mktemp -d); trap "rm -rf ${TEMP_DIR}" EXIT + GH_REPO=$(git remote get-url origin | sed 's/.*github.com[:/]\(.*\)\.git/\1/' || echo "") + [ -n "${GH_REPO}" ] || { echo "Error: could not determine GitHub repository"; exit 1; } + echo "Downloading uki-${ARCH}.zip from release ${VERSION}..." + gh release download "${VERSION}" --repo "${GH_REPO}" --pattern "uki-${ARCH}.zip" --dir "${TEMP_DIR}" + gh attestation verify "${TEMP_DIR}/uki-${ARCH}.zip" --repo "${GH_REPO}" \ + || { echo "Error: attestation verification failed"; exit 1; } + unzip -q "${TEMP_DIR}/uki-${ARCH}.zip" -d "${TEMP_DIR}" + WORK_DIR="${TEMP_DIR}" +else + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" + WORK_DIR="${REPO_ROOT}/tools/build-uki/${ARCH}" +fi + +UKI_FILE="${WORK_DIR}/linux.efi" +[ -f "${UKI_FILE}" ] || { echo "Error: ${UKI_FILE} not found (build the UKI first)"; exit 1; } + +# Pin = sha256 of the bytes we upload. Cross-check against the build's recorded +# pin if present, to catch a stale/mismatched linux.efi.sha256. +SHA256=$(sha256sum "${UKI_FILE}" | awk '{print $1}') +if [ -f "${WORK_DIR}/linux.efi.sha256" ]; then + RECORDED=$(awk '{print $1}' "${WORK_DIR}/linux.efi.sha256") + if [ "${RECORDED}" != "${SHA256}" ]; then + echo "Error: linux.efi.sha256 (${RECORDED}) != actual (${SHA256})"; exit 1 + fi +fi + +KEY="linux-${ARCH}.efi" +OBJECT="${DEST_URI}/${KEY}" + +case "${DEST_URI}" in + s3://*) + BUCKET="${DEST_URI#s3://}"; BUCKET="${BUCKET%%/*}" + PREFIX="${DEST_URI#s3://${BUCKET}}"; PREFIX="${PREFIX#/}" + aws s3 cp "${UKI_FILE}" "${OBJECT}" + URL="https://${BUCKET}.s3.amazonaws.com/${PREFIX:+${PREFIX}/}${KEY}" + ;; + gs://*) + BUCKET="${DEST_URI#gs://}"; BUCKET="${BUCKET%%/*}" + PREFIX="${DEST_URI#gs://${BUCKET}}"; PREFIX="${PREFIX#/}" + gcloud storage cp "${UKI_FILE}" "${OBJECT}" + URL="https://storage.googleapis.com/${BUCKET}/${PREFIX:+${PREFIX}/}${KEY}" + ;; + *) + echo "Error: dest-uri must start with s3:// or gs://"; exit 1 + ;; +esac + +echo "Uploaded: ${OBJECT}" +echo "sha256: ${SHA256}" +echo "" +echo "Paste into instance user-data (_stage1, sha256 admission mode):" +cat < 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 @@ -46,6 +49,7 @@ BOOT_DISK="" USER_DATA="" OVMF_VARS_OVERRIDE="" PAYLOAD="" +SERVE_DIR="" TRACE=0 while [ $# -gt 0 ]; do @@ -56,6 +60,7 @@ while [ $# -gt 0 ]; do --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 ;; @@ -217,24 +222,29 @@ echo $! > $TMP/ec2-mock.pid # Give services time to start sleep 1 -# Optionally serve the stage0 payload over HTTP on the tap gateway, so a -# `_stage0` user-data can point at http://10.0.2.1:8000/payload.efi. -if [ -n "${PAYLOAD}" ]; then - if [ ! -f "${PAYLOAD}" ]; then - echo "Error: payload ${PAYLOAD} not found"; exit 1 - fi - PAYLOAD_DIR=$(mktemp -d) - cp "${PAYLOAD}" "${PAYLOAD_DIR}/payload.efi" - # In signed mode stage0 also fetches a detached signature at .sig; - # serve it alongside the payload if the build produced one. - [ -f "${PAYLOAD}.sig" ] && cp "${PAYLOAD}.sig" "${PAYLOAD_DIR}/payload.efi.sig" - # Serve over HTTP/1.1 (with Content-Length + keep-alive). The default - # `python -m http.server` speaks HTTP/1.0 with `Connection: close`, which - # OVMF's HttpDxe does not complete the response token for. Real cloud object - # stores (S3/GCS) serve HTTP/1.1, so this matches production. - ( cd "${PAYLOAD_DIR}" && 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()' ) & +# 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 payload (HTTP/1.1) at http://10.0.2.1:8000/payload.efi (sha256 $(sha256sum "${PAYLOAD}" | cut -d' ' -f1))" + 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