diff --git a/.gitignore b/.gitignore index 763b299b1cf2..bf515d0fdbf9 100644 --- a/.gitignore +++ b/.gitignore @@ -79,6 +79,11 @@ disk.img *-img.tar.zst dev-root-ca.crt +# Downloaded SEV recovery inputs (fetched by build-sev-recovery.sh) +ic-os/guestos/envs/sev-recovery/alternative_guestos_proposal.cbor +ic-os/guestos/envs/sev-recovery/base-update-img.tar.zst +ic-os/guestos/envs/sev-recovery/update-img-*.tar.zst + # IC-OS binaries cpp/**/infogetty cpp/**/infogetty.o diff --git a/Cargo.lock b/Cargo.lock index 6edfb8d066e8..c34c42d0c813 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -382,6 +382,38 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "alternative_guestos" +version = "0.0.0" +dependencies = [ + "anyhow", + "candid", + "ic-certificate-verification", + "ic-certification 3.1.0", + "ic-nns-constants", + "ic-nns-governance-api", + "serde_cbor", +] + +[[package]] +name = "alternative_guestos_tool" +version = "0.0.0" +dependencies = [ + "alternative_guestos", + "anyhow", + "candid", + "clap 4.6.0", + "ic-agent", + "ic-certification 3.1.0", + "ic-nns-constants", + "ic-nns-governance-api", + "ic-nns-governance-conversions", + "ic-protobuf", + "serde_cbor", + "serde_json", + "tokio", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -19128,6 +19160,7 @@ dependencies = [ name = "open_rootfs" version = "0.0.0" dependencies = [ + "alternative_guestos", "anyhow", "attestation", "candid", diff --git a/Cargo.toml b/Cargo.toml index b8abae7fa090..8ffe07f55f99 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -165,6 +165,8 @@ members = [ "rs/ic_os/build_tools/dflate", "rs/ic_os/build_tools/diroid", "rs/ic_os/build_tools/partition_tools", + "rs/ic_os/build_tools/alternative_guestos", + "rs/ic_os/alternative_guestos", "rs/ic_os/command_runner", "rs/ic_os/config/tool", "rs/ic_os/config/types", diff --git a/ic-os/defs.bzl b/ic-os/defs.bzl index fbfdf8e8ad9c..60d4ade94182 100644 --- a/ic-os/defs.bzl +++ b/ic-os/defs.bzl @@ -22,6 +22,7 @@ def icos_build( image_deps_func, mode = None, malicious = False, + build_alternative_guestos_image = False, upgrades = True, vuln_scan = True, visibility = None, @@ -36,6 +37,7 @@ def icos_build( image_deps_func: Function to be used to generate image manifest mode: dev or prod. If not specified, will use the value of `name` malicious: if True, bundle the `malicious_replica` + build_alternative_guestos_image: if True, build the proposal-aware alternative GuestOS image variant, e.g. for a recovery upgrades: if True, build upgrade images as well vuln_scan: if True, create targets for vulnerability scanning visibility: See Bazel documentation @@ -133,21 +135,40 @@ def icos_build( tags = ["manual"], ) - # Extract initrd and kernel for SEV measurement - tar_extract( - name = "extracted_initrd.img", - src = "rootfs-tree.tar", - path = "boot/initrd.img-*", - wildcards = True, - tags = ["manual"], - ) + # Extract the bootfs tree from the base GuestOS update image. The image is + # supplied by build-sev-recovery as a source file `base-update-img.tar.zst`, + # a symlink to a version-named `update-img-.tar.zst` + # (see //ic-os/guestos/envs/sev-recovery:build-sev-recovery). + native.genrule( + name = "alternative_guestos_base_bootfs_tree_tar", + srcs = [":base-update-img.tar.zst"], + outs = ["alternative_guestos_base_bootfs_tree.tar"], + cmd = """ +set -euo pipefail - tar_extract( - name = "extracted_vmlinuz", - src = "rootfs-tree.tar", - path = "boot/vmlinuz-*", - wildcards = True, - tags = ["manual"], +tmpdir=$$(mktemp -d) +mounted=0 +cleanup() { + set +e + if [[ $$mounted -eq 1 ]]; then + fusermount3 -u "$$tmpdir/bootfs" || fusermount -u "$$tmpdir/bootfs" || umount "$$tmpdir/bootfs" + fi + rm -rf "$$tmpdir" +} +trap cleanup EXIT + +# Extract boot.img from the base GuestOS update image. +tar --extract --zstd --to-stdout --file "$<" boot.img > "$$tmpdir/boot.img" + +mkdir "$$tmpdir/bootfs" +$(location //:fuse2fs) -o ro,norecovery,fakeroot "$$tmpdir/boot.img" "$$tmpdir/bootfs" +mounted=1 +tar --create --file "$@" --numeric-owner -C "$$tmpdir/bootfs" . +""", + message = "Extracting base GuestOS boot partition via fuse2fs", + tags = ["manual", "no-cache"], + target_compatible_with = ["@platforms//os:linux"], + tools = ["//:fuse2fs"], ) # -------------------- Extract root and boot partitions -------------------- @@ -199,29 +220,79 @@ def icos_build( tags = ["manual", "no-cache"], ) - ext4_image( - name = partition_boot_tzst, - src = ":rootfs-tree.tar", - file_contexts = ":file_contexts", - partition_size = image_deps["bootfs_size"], - subdir = "boot", + if build_alternative_guestos_image: + ext4_image( + name = partition_boot_tzst, + src = ":alternative_guestos_base_bootfs_tree_tar", + file_contexts = ":file_contexts", + partition_size = image_deps["bootfs_size"], + target_compatible_with = ["@platforms//os:linux"], + extra_files = { + ":alternative_guestos_proposal.cbor": "/alternative_guestos_proposal.cbor:0644", + }, + tags = ["manual", "no-cache"], + ) + else: + ext4_image( + name = partition_boot_tzst, + src = ":rootfs-tree.tar", + file_contexts = ":file_contexts", + partition_size = image_deps["bootfs_size"], + subdir = "boot", + target_compatible_with = ["@platforms//os:linux"], + extra_files = { + k: v + for k, v in ( + image_deps["bootfs"].items() + [ + (version_txt, "/version.txt:0644"), + (boot_args, "/boot_args:0644"), + (image_deps["grub_config"], "/grub.cfg:0644"), + ] + ) + }, + tags = ["manual", "no-cache"], + ) + + # Extract individual files (boot args, initrd, vmlinuz, OVMF firmware) from the + # boot partition image for use in launch measurement generation and boot arg aliasing. + extracted_boot_args = "extracted_boot" + test_suffix + "_args" + extracted_initrd = "extracted_initrd" + test_suffix + ".img" + extracted_vmlinuz = "extracted_vmlinuz" + test_suffix + extracted_ovmf_sev = "extracted_OVMF_SEV" + test_suffix + ".fd" + + native.genrule( + name = "extract_boot_partition_files" + test_suffix, + srcs = [":" + partition_boot_tzst], + outs = [ + extracted_boot_args, + extracted_initrd, + extracted_vmlinuz, + extracted_ovmf_sev, + ], + cmd = """ + tmpdir="$$(mktemp -d)" + trap 'rm -rf "$$tmpdir"' EXIT + + tar --extract -a --file "$<" --directory "$$tmpdir" + + # initrd and vmlinuz are symlinks, first extract the symlink targets + initrd_target="$$(/usr/sbin/debugfs -R "stat /initrd.img" "$$tmpdir/partition.img" | sed -n 's/^Fast link dest: "\\(.*\\)"$$/\\1/p')" + vmlinuz_target="$$(/usr/sbin/debugfs -R "stat /vmlinuz" "$$tmpdir/partition.img" | sed -n 's/^Fast link dest: "\\(.*\\)"$$/\\1/p')" + + /usr/sbin/debugfs -R "dump /boot_args $(@D)/""" + extracted_boot_args + """" "$$tmpdir/partition.img" + /usr/sbin/debugfs -R "dump /OVMF_SEV.fd $(@D)/""" + extracted_ovmf_sev + """" "$$tmpdir/partition.img" + /usr/sbin/debugfs -R "dump /$$initrd_target $(@D)/""" + extracted_initrd + """" "$$tmpdir/partition.img" + /usr/sbin/debugfs -R "dump /$$vmlinuz_target $(@D)/""" + extracted_vmlinuz + """" "$$tmpdir/partition.img" + """, + message = "Extracting files from boot partition", + tags = ["manual"], target_compatible_with = ["@platforms//os:linux"], - extra_files = { - k: v - for k, v in ( - image_deps["bootfs"].items() + [ - (version_txt, "/version.txt:0644"), - (boot_args, "/boot_args:0644"), - (image_deps["grub_config"], "/grub.cfg:0644"), - ] - ) - }, - tags = ["manual", "no-cache"], ) - # The kernel command line (boot args) is generated from boot_args_template: + # The kernel command line (boot args) is usually generated from boot_args_template: # - For OS requiring root signing: Template includes ROOT_HASH placeholder that gets substituted with dm-verity hash # - For OS not requiring root signing: Template is used as-is without ROOT_HASH substitution + # - For alternative GuestOS image builds: The base release boot_args file is reused as-is # # This provides: # - Consistent boot argument handling across all OS types @@ -251,20 +322,28 @@ def icos_build( tags = ["manual", "no-cache"], visibility = ["//rs/tests:__subpackages__", "//ic-os:__subpackages__"], ) - native.genrule( - name = "generate-" + boot_args, - outs = [boot_args], - srcs = [partition_root_hash, ":boot_args_template"], - cmd = "sed -e s/ROOT_HASH/$$(cat $(location " + partition_root_hash + "))/ " + - "< $(location :boot_args_template) > $@", - tags = ["manual"], - ) + + if build_alternative_guestos_image: + native.alias( + name = boot_args, + actual = ":" + extracted_boot_args, + tags = ["manual"], + ) + else: + native.genrule( + name = "generate-" + boot_args, + outs = [boot_args], + srcs = [partition_root_hash, ":boot_args_template"], + cmd = "sed -e s/ROOT_HASH/$$(cat $(location " + partition_root_hash + "))/ " + + "< $(location :boot_args_template) > $@", + tags = ["manual"], + ) else: # No signing required, no ROOT_HASH substitution native.alias(name = partition_root_signed_tzst, actual = partition_root_unsigned_tzst, tags = ["manual", "no-cache"]) native.alias( name = boot_args, - actual = ":boot_args_template", + actual = ":" + extracted_boot_args if build_alternative_guestos_image else ":boot_args_template", tags = ["manual"], ) @@ -280,22 +359,26 @@ def icos_build( # Supported CPU types for sev-snp-measure, one per AMD EPYC generation. vcpu_types = ["EPYC-v4", "EPYC-Genoa", "EPYC-Turin"] - native.genrule( - name = "generate-" + launch_measurements, - outs = [launch_measurements], - srcs = ["//ic-os/components/ovmf:ovmf_sev", boot_args, ":extracted_initrd.img", ":extracted_vmlinuz"], - visibility = visibility, - tools = ["//ic-os:sev-snp-measure"], - tags = ["manual"], - cmd = r""" - source $(execpath """ + boot_args + """) + # Build the genrule inputs and command conditionally. When building an + # alternative GuestOS image (e.g. for recovery), the generated measurements + # are validated against the proposal *in the same genrule* so that the + # validation can never become a leaf target that is silently skipped. + measurement_srcs = [ + ":" + extracted_ovmf_sev, + ":" + extracted_boot_args, + ":" + extracted_initrd, + ":" + extracted_vmlinuz, + ] + measurement_tools = ["//ic-os:sev-snp-measure"] + measurement_cmd = r""" + source $(execpath :""" + extracted_boot_args + r""") # Create GuestLaunchMeasurements JSON for each CPU generation, vCPU count, and boot slot (for vcpu_type in """ + " ".join(vcpu_types) + """; do for vcpus in """ + vcpu_configs + """; do # Note: We only create launch measurements for the TEE boot arg variants # (BOOT_ARGS_TEE_A and BOOT_ARGS_TEE_B) for cmdline in "$$BOOT_ARGS_TEE_A" "$$BOOT_ARGS_TEE_B"; do - hex=$$($(execpath //ic-os:sev-snp-measure) --mode snp --vcpus $$vcpus --ovmf "$(execpath //ic-os/components/ovmf:ovmf_sev)" --vcpu-type "$$vcpu_type" --append "$$cmdline" --initrd "$(location extracted_initrd.img)" --kernel "$(location extracted_vmlinuz)") + hex=$$($(execpath //ic-os:sev-snp-measure) --mode snp --vcpus $$vcpus --ovmf "$(execpath """ + extracted_ovmf_sev + """)" --vcpu-type "$$vcpu_type" --append "$$cmdline" --initrd "$(location """ + extracted_initrd + """)" --kernel "$(location """ + extracted_vmlinuz + """)") # Convert hex string to decimal list, e.g. "abcd" -> 171\\n205 measurement=$$(echo -n "$$hex" | fold -w2 | sed "s/^/0x/" | xargs printf "%d\n") jq -na --arg cmd "$$cmdline" --arg m "$$measurement" --arg vcpu_type "$$vcpu_type" '{ @@ -305,7 +388,31 @@ def icos_build( done done done) | jq -sc "{guest_launch_measurements: .}" > $@ - """, + """ + + # When building an alternative GuestOS image, verify that the generated launch measurements correspond + # to those from the proposal. We keep this inside the same build rule, otherwise the check could be skipped + # by Bazel. + if build_alternative_guestos_image: + measurement_srcs.append(":alternative_guestos_proposal.cbor") + measurement_tools.append( + "//rs/ic_os/build_tools/alternative_guestos", + ) + measurement_cmd += r""" + # Validate the generated measurements against the alternative GuestOS proposal + $(execpath //rs/ic_os/build_tools/alternative_guestos) validate-measurements \ + --proposal-path $(location :alternative_guestos_proposal.cbor) \ + --local-measurements-path $@ + """ + + native.genrule( + name = "generate-" + launch_measurements, + outs = [launch_measurements], + srcs = measurement_srcs, + visibility = visibility, + tools = measurement_tools, + tags = ["manual"], + cmd = measurement_cmd, ) component_file_references_test( diff --git a/ic-os/guestos/envs/sev-recovery/BUILD.bazel b/ic-os/guestos/envs/sev-recovery/BUILD.bazel new file mode 100644 index 000000000000..368e80bc6530 --- /dev/null +++ b/ic-os/guestos/envs/sev-recovery/BUILD.bazel @@ -0,0 +1,24 @@ +load("@rules_shell//shell:sh_binary.bzl", "sh_binary") +load("//ic-os:defs.bzl", "icos_build") +load("//ic-os/guestos:defs.bzl", "image_deps") + +# Builds an SEV recovery image when an elected proposal is available: +# bazel run //ic-os/guestos/envs/sev-recovery:build-sev-recovery -- \ +# --base= --proposal= +icos_images = icos_build( + name = "sev-recovery", + build_alternative_guestos_image = True, + image_deps_func = image_deps, + mode = "prod", + visibility = [ + "//rs:ic-os-pkg", + ], +) + +# Downloads the certified recovery proposal (the only non-hermetic step) and then +# builds the SEV recovery image. See the script header for details. +sh_binary( + name = "build-sev-recovery", + srcs = ["build-sev-recovery.sh"], + visibility = ["//visibility:public"], +) diff --git a/ic-os/guestos/envs/sev-recovery/build-sev-recovery.sh b/ic-os/guestos/envs/sev-recovery/build-sev-recovery.sh new file mode 100755 index 000000000000..3e531b643912 --- /dev/null +++ b/ic-os/guestos/envs/sev-recovery/build-sev-recovery.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# +# Builds an SEV recovery GuestOS image. +# +# Usage: +# bazel run //ic-os/guestos/envs/sev-recovery:build-sev-recovery -- \ +# --base= --proposal= +# +# --base git commit of the GuestOS release running on the node being +# recovered. The matching GuestOS update image is downloaded here. +# --proposal id of the accepted BlessAlternativeGuestOsVersion proposal that +# corresponds to the base version (its launch measurements must +# match, which is checked at build time). + +set -euo pipefail + +# `bazel run` executes this script from inside its output tree; switch back to +# the workspace so the nested `bazel` invocations and relative paths work. +WORKSPACE="${BUILD_WORKING_DIRECTORY:?must be invoked via \`bazel run\`}" +cd "$WORKSPACE" + +base_version="" +proposal_id="" +for arg in "$@"; do + case "$arg" in + --base=*) base_version="${arg#--base=}" ;; + --proposal=*) proposal_id="${arg#--proposal=}" ;; + *) echo "unknown argument: $arg" >&2; exit 1 ;; + esac +done + +if [[ -z "$base_version" || -z "$proposal_id" ]]; then + echo "usage: build-sev-recovery -- --base= --proposal=" >&2 + exit 1 +fi + +TARGET_PACKAGE="ic-os/guestos/envs/sev-recovery" +CBOR="$TARGET_PACKAGE/alternative_guestos_proposal.cbor" +IMG="$TARGET_PACKAGE/update-img-$base_version.tar.zst" +LINK="$TARGET_PACKAGE/base-update-img.tar.zst" +UPDATE_IMG_URL="https://download.dfinity.systems/ic/$base_version/guest-os/update-img/update-img.tar.zst" +TARGET="//$TARGET_PACKAGE:update-img.tar.zst" + +echo "==> Downloading base GuestOS update image ($base_version)..." +# Cache by version in the filename (skip the download if already present), then +# point the fixed-name symlink — the build's input — at this version's image. +if [[ ! -f "$IMG" ]]; then + curl --fail --silent --show-error --location "$UPDATE_IMG_URL" -o "$WORKSPACE/$IMG" +else + echo " $IMG already present, skipping download." +fi +ln -sfn "$(basename "$IMG")" "$WORKSPACE/$LINK" + +echo "==> Downloading signed proposal ${proposal_id}..." +bazel run //rs/ic_os/build_tools/alternative_guestos -- download-signed-proposal \ + --proposal-id "$proposal_id" \ + --output "$WORKSPACE/$CBOR" + +echo "==> Building SEV recovery image..." +bazel build "$TARGET" diff --git a/rs/ic_os/alternative_guestos/BUILD.bazel b/rs/ic_os/alternative_guestos/BUILD.bazel new file mode 100644 index 000000000000..82028b170031 --- /dev/null +++ b/rs/ic_os/alternative_guestos/BUILD.bazel @@ -0,0 +1,18 @@ +load("@rules_rust//rust:defs.bzl", "rust_library") + +package(default_visibility = ["//visibility:public"]) + +rust_library( + name = "alternative_guestos", + srcs = glob(["src/**/*.rs"]), + crate_name = "alternative_guestos", + deps = [ + "//rs/nns/constants", + "//rs/nns/governance/api", + "@crate_index//:anyhow", + "@crate_index//:candid", + "@crate_index//:ic-certificate-verification", + "@crate_index//:ic-certification", + "@crate_index//:serde_cbor", + ], +) diff --git a/rs/ic_os/alternative_guestos/Cargo.toml b/rs/ic_os/alternative_guestos/Cargo.toml new file mode 100644 index 000000000000..92b993187812 --- /dev/null +++ b/rs/ic_os/alternative_guestos/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "alternative_guestos" +edition.workspace = true + +[dependencies] +anyhow = { workspace = true } +candid = { workspace = true } +ic-certificate-verification = { workspace = true } +ic-certification = { workspace = true } +serde_cbor = { workspace = true } + +ic-nns-constants = { path = "../../nns/constants" } +ic-nns-governance-api = { path = "../../nns/governance/api" } diff --git a/rs/ic_os/alternative_guestos/src/lib.rs b/rs/ic_os/alternative_guestos/src/lib.rs new file mode 100644 index 000000000000..0f69202813a3 --- /dev/null +++ b/rs/ic_os/alternative_guestos/src/lib.rs @@ -0,0 +1 @@ +pub mod proposal; diff --git a/rs/ic_os/open_rootfs/src/proposal.rs b/rs/ic_os/alternative_guestos/src/proposal.rs similarity index 94% rename from rs/ic_os/open_rootfs/src/proposal.rs rename to rs/ic_os/alternative_guestos/src/proposal.rs index 72c40b60d8f6..a4978fb208bf 100644 --- a/rs/ic_os/open_rootfs/src/proposal.rs +++ b/rs/ic_os/alternative_guestos/src/proposal.rs @@ -23,7 +23,7 @@ const NNS_PUBLIC_KEY: &[u8; 133] = b"\x30\x81\x82\x30\x1d\x06\x0d\x2b\x06\x01\x0 /// (e.g. by using ic-agent's Agent::update().call().and_wait()) and storing it in a file. pub fn read_and_verify_signed_bless_alternative_guest_os_version_proposal( proposal_path: &Path, - #[cfg(any(feature = "dev", test))] nns_public_key_override: Option<&[u8]>, + nns_public_key_override: Option<&[u8]>, ) -> Result { ensure!( proposal_path.exists(), @@ -37,10 +37,7 @@ pub fn read_and_verify_signed_bless_alternative_guest_os_version_proposal( let certificate: Certificate = serde_cbor::from_slice(&proposal_bytes) .context("Failed to deserialize Certificate from CBOR")?; - let nns_public_key = NNS_PUBLIC_KEY; - // Allow overriding the NNS public key if running in dev or test mode - #[cfg(any(feature = "dev", test))] - let nns_public_key = nns_public_key_override.unwrap_or(nns_public_key); + let nns_public_key = nns_public_key_override.unwrap_or(NNS_PUBLIC_KEY); // Verify the certificate against the NNS public key. This is the proof that the proposal // came from the NNS governance canister. diff --git a/rs/ic_os/build_tools/alternative_guestos/BUILD.bazel b/rs/ic_os/build_tools/alternative_guestos/BUILD.bazel new file mode 100644 index 000000000000..28685b5606c2 --- /dev/null +++ b/rs/ic_os/build_tools/alternative_guestos/BUILD.bazel @@ -0,0 +1,28 @@ +load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_test") + +package(default_visibility = ["//visibility:public"]) + +rust_binary( + name = "alternative_guestos", + srcs = glob(["src/**/*.rs"]), + deps = [ + "//rs/ic_os/alternative_guestos", + "//rs/nns/constants", + "//rs/nns/governance/api", + "//rs/nns/governance/conversions", + "//rs/protobuf", + "@crate_index//:anyhow", + "@crate_index//:candid", + "@crate_index//:clap", + "@crate_index//:ic-agent", + "@crate_index//:ic-certification", + "@crate_index//:serde_cbor", + "@crate_index//:serde_json", + "@crate_index//:tokio", + ], +) + +rust_test( + name = "alternative_guestos_test", + crate = ":alternative_guestos", +) diff --git a/rs/ic_os/build_tools/alternative_guestos/Cargo.toml b/rs/ic_os/build_tools/alternative_guestos/Cargo.toml new file mode 100644 index 000000000000..83aa01ccb3d6 --- /dev/null +++ b/rs/ic_os/build_tools/alternative_guestos/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "alternative_guestos_tool" +edition.workspace = true + +[dependencies] +anyhow = { workspace = true } +candid = { workspace = true } +clap = { workspace = true } +ic-agent = { workspace = true } +ic-certification = { workspace = true } +serde_cbor = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } + +alternative_guestos = { path = "../../alternative_guestos" } +ic-nns-constants = { path = "../../../nns/constants" } +ic-nns-governance-api = { path = "../../../nns/governance/api" } +ic-nns-governance-conversions = { path = "../../../nns/governance/conversions" } +ic-protobuf = { path = "../../../protobuf" } \ No newline at end of file diff --git a/rs/ic_os/build_tools/alternative_guestos/src/download.rs b/rs/ic_os/build_tools/alternative_guestos/src/download.rs new file mode 100644 index 000000000000..fdd7159504b4 --- /dev/null +++ b/rs/ic_os/build_tools/alternative_guestos/src/download.rs @@ -0,0 +1,50 @@ +use alternative_guestos::proposal::read_and_verify_signed_bless_alternative_guest_os_version_proposal; +use anyhow::{Context, Result}; +use candid::{Encode, Principal}; +use ic_agent::Agent; +use ic_certification::Certificate; +use ic_nns_constants::GOVERNANCE_CANISTER_ID; +use std::fs; +use std::path::Path; + +pub async fn download_signed_proposal( + proposal_id: u64, + nns_url: &str, + output: &Path, +) -> Result<()> { + let governance_canister_id = Principal::from(GOVERNANCE_CANISTER_ID); + let agent = Agent::builder() + .with_url(nns_url) + .build() + .context("Failed to build NNS agent")?; + let args = Encode!(&proposal_id).context("Failed to encode proposal id")?; + let (_reply, certificate): (Vec, Certificate) = agent + .update(&governance_canister_id, "get_proposal_info") + .with_arg(args) + .call() + .and_wait() + .await + .context("Failed to download certified get_proposal_info response")?; + + fs::write( + output, + serde_cbor::to_vec(&certificate).context("Failed to serialize certificate as CBOR")?, + ) + .with_context(|| { + format!( + "Failed to write proposal certificate to {}", + output.display() + ) + })?; + + read_and_verify_signed_bless_alternative_guest_os_version_proposal(output, None).with_context( + || { + format!( + "Downloaded proposal at {} failed verification", + output.display() + ) + }, + )?; + + Ok(()) +} diff --git a/rs/ic_os/build_tools/alternative_guestos/src/main.rs b/rs/ic_os/build_tools/alternative_guestos/src/main.rs new file mode 100644 index 000000000000..ecb78bd3e0d9 --- /dev/null +++ b/rs/ic_os/build_tools/alternative_guestos/src/main.rs @@ -0,0 +1,62 @@ +use crate::download::download_signed_proposal; +use crate::proposal_build::validate_measurements; +use alternative_guestos::proposal::read_and_verify_signed_bless_alternative_guest_os_version_proposal; +use anyhow::{Context, Result}; +use clap::{Parser, Subcommand}; +use std::fs; +use std::path::PathBuf; + +mod download; +mod proposal_build; + +#[derive(Parser)] +struct Args { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + DownloadSignedProposal { + #[arg(long)] + proposal_id: u64, + #[arg(long, default_value = "https://ic0.app")] + nns_url: String, + #[arg(long)] + output: PathBuf, + }, + ValidateMeasurements { + #[arg(long)] + proposal_path: PathBuf, + #[arg(long)] + local_measurements_path: PathBuf, + }, +} + +#[tokio::main] +async fn main() -> Result<()> { + match Args::parse().command { + Command::DownloadSignedProposal { + proposal_id, + nns_url, + output, + } => download_signed_proposal(proposal_id, &nns_url, &output).await, + Command::ValidateMeasurements { + proposal_path, + local_measurements_path, + } => { + let proposal = read_and_verify_signed_bless_alternative_guest_os_version_proposal( + &proposal_path, + None, + )?; + let local_measurements = + fs::read_to_string(&local_measurements_path).with_context(|| { + format!( + "Failed to read local measurements from {}", + local_measurements_path.display() + ) + })?; + validate_measurements(&proposal, &local_measurements) + } + } +} diff --git a/rs/ic_os/build_tools/alternative_guestos/src/proposal_build.rs b/rs/ic_os/build_tools/alternative_guestos/src/proposal_build.rs new file mode 100644 index 000000000000..ce2b545c62f0 --- /dev/null +++ b/rs/ic_os/build_tools/alternative_guestos/src/proposal_build.rs @@ -0,0 +1,129 @@ +use anyhow::{Context, Result, ensure}; +use ic_nns_governance_api::BlessAlternativeGuestOsVersion; +use ic_nns_governance_conversions::convert_guest_launch_measurements_from_api_to_pb; +use ic_protobuf::registry::replica_version::v1::{GuestLaunchMeasurement, GuestLaunchMeasurements}; +use std::collections::HashSet; + +pub fn validate_measurements( + proposal: &BlessAlternativeGuestOsVersion, + local_measurements_json: &str, +) -> Result<()> { + let local_measurements: GuestLaunchMeasurements = serde_json::from_str(local_measurements_json) + .context("Failed to parse locally generated launch measurements JSON")?; + let proposal_measurements = proposal_measurements(proposal)?; + let local_measurements = local_measurements.guest_launch_measurements; + let local_measurement_bytes = measurement_bytes(&local_measurements); + let proposal_measurement_bytes = measurement_bytes(&proposal_measurements); + + let shared_measurement_count = local_measurement_bytes + .intersection(&proposal_measurement_bytes) + .count(); + + ensure!( + shared_measurement_count > 0, + "Local and proposal measurements do not overlap.\nLocal measurements: {:?}\nProposal measurements: {:?}", + local_measurements, + proposal_measurements, + ); + + Ok(()) +} + +fn measurement_bytes(measurements: &[GuestLaunchMeasurement]) -> HashSet> { + measurements + .iter() + .map(|measurement| measurement.measurement.clone()) + .collect() +} + +fn proposal_measurements( + proposal: &BlessAlternativeGuestOsVersion, +) -> Result> { + Ok(convert_guest_launch_measurements_from_api_to_pb( + proposal + .base_guest_launch_measurements + .clone() + .context("Proposal is missing base_guest_launch_measurements")?, + ) + .guest_launch_measurements) +} + +#[cfg(test)] +mod tests { + use super::*; + use ic_nns_governance_api::{ + GuestLaunchMeasurement as ApiGuestLaunchMeasurement, + GuestLaunchMeasurementMetadata as ApiGuestLaunchMeasurementMetadata, + GuestLaunchMeasurements as ApiGuestLaunchMeasurements, + }; + use serde_json::json; + + fn proposal( + kernel_cmdlines: &[&str], + measurements: &[Vec], + ) -> BlessAlternativeGuestOsVersion { + BlessAlternativeGuestOsVersion { + chip_ids: Some(vec![vec![0; 64]]), + rootfs_hash: Some("ignored-by-build-tool".into()), + base_guest_launch_measurements: Some(ApiGuestLaunchMeasurements { + guest_launch_measurements: Some( + kernel_cmdlines + .iter() + .zip(measurements.iter()) + .map(|(kernel_cmdline, measurement)| ApiGuestLaunchMeasurement { + measurement: Some(measurement.clone()), + metadata: Some(ApiGuestLaunchMeasurementMetadata { + kernel_cmdline: Some((*kernel_cmdline).into()), + vcpu_type: Some("EPYC-Genoa".into()), + }), + }) + .collect(), + ), + }), + } + } + + #[test] + fn accepts_exact_measurement_match() { + let proposal = proposal( + &[ + "console=ttyS0 root_hash=abcd", + "console=ttyS0 root_hash=abcd dfinity.tee=1", + ], + &[vec![1, 2], vec![3, 4]], + ); + let local_measurements = json!({ + "guest_launch_measurements": [ + {"measurement": [1, 2], "metadata": {"kernel_cmdline": "console=ttyS0 root_hash=abcd", "vcpu_type": "EPYC-Genoa"}}, + {"measurement": [3, 4], "metadata": {"kernel_cmdline": "console=ttyS0 root_hash=abcd dfinity.tee=1", "vcpu_type": "EPYC-Genoa"}} + ] + }); + + validate_measurements(&proposal, &local_measurements.to_string()).unwrap(); + } + + #[test] + fn accepts_when_local_has_extra_measurement_but_overlap_exists() { + let proposal = proposal(&["console=ttyS0 root_hash=abcd"], &[vec![1, 2]]); + let local_measurements = json!({ + "guest_launch_measurements": [ + {"measurement": [1, 2], "metadata": {"kernel_cmdline": "console=ttyS0 root_hash=abcd", "vcpu_type": "EPYC-Genoa"}}, + {"measurement": [3, 4], "metadata": {"kernel_cmdline": "console=ttyS0 root_hash=abcd dfinity.tee=1", "vcpu_type": "EPYC-Genoa"}} + ] + }); + + validate_measurements(&proposal, &local_measurements.to_string()).unwrap(); + } + + #[test] + fn rejects_when_measurements_do_not_overlap() { + let proposal = proposal(&["console=ttyS0 root_hash=abcd"], &[vec![1, 2]]); + let local_measurements = json!({ + "guest_launch_measurements": [ + {"measurement": [3, 4], "metadata": {"kernel_cmdline": "console=ttyS0 root_hash=other", "vcpu_type": "EPYC-Genoa"}} + ] + }); + + assert!(validate_measurements(&proposal, &local_measurements.to_string()).is_err()); + } +} diff --git a/rs/ic_os/config/tool/src/guestos/bootstrap_ic_node.rs b/rs/ic_os/config/tool/src/guestos/bootstrap_ic_node.rs index b5ac4d7ecd07..f4b5d0caa063 100644 --- a/rs/ic_os/config/tool/src/guestos/bootstrap_ic_node.rs +++ b/rs/ic_os/config/tool/src/guestos/bootstrap_ic_node.rs @@ -1,5 +1,4 @@ use anyhow::{Context, Result}; -#[cfg(target_os = "linux")] use config_types::GuestOSConfig; use fs_extra; use std::fs::{self, File}; diff --git a/rs/ic_os/open_rootfs/BUILD.bazel b/rs/ic_os/open_rootfs/BUILD.bazel index ecac7acd4624..dd60ff7a80b9 100644 --- a/rs/ic_os/open_rootfs/BUILD.bazel +++ b/rs/ic_os/open_rootfs/BUILD.bazel @@ -7,6 +7,7 @@ rust_binary( srcs = glob(["src/**/*.rs"]), deps = [ # Keep sorted. + "//rs/ic_os/alternative_guestos", "//rs/ic_os/command_runner", "//rs/ic_os/config/tool:config_lib", "//rs/ic_os/config/types:config_types", @@ -37,6 +38,7 @@ rust_binary( crate_features = ["dev"], deps = [ # Keep sorted. + "//rs/ic_os/alternative_guestos", "//rs/ic_os/command_runner", "//rs/ic_os/config/tool:config_lib", "//rs/ic_os/config/types:config_types", diff --git a/rs/ic_os/open_rootfs/Cargo.toml b/rs/ic_os/open_rootfs/Cargo.toml index 978f8eac468d..be896ea89fb5 100644 --- a/rs/ic_os/open_rootfs/Cargo.toml +++ b/rs/ic_os/open_rootfs/Cargo.toml @@ -21,6 +21,7 @@ serde_cbor = { workspace = true } tracing = { workspace = true } uuid = { workspace = true } +alternative_guestos = { path = "../alternative_guestos" } attestation = { path = "../sev/attestation" } command_runner = { path = "../command_runner" } config_tool = { path = "../config/tool" } diff --git a/rs/ic_os/open_rootfs/src/main.rs b/rs/ic_os/open_rootfs/src/main.rs index 8a098c700615..ced7b87103f4 100644 --- a/rs/ic_os/open_rootfs/src/main.rs +++ b/rs/ic_os/open_rootfs/src/main.rs @@ -1,5 +1,4 @@ mod partitions; -mod proposal; mod recovery; mod verity; diff --git a/rs/ic_os/open_rootfs/src/recovery.rs b/rs/ic_os/open_rootfs/src/recovery.rs index a51ef990bee8..a5e32bbe5a2e 100644 --- a/rs/ic_os/open_rootfs/src/recovery.rs +++ b/rs/ic_os/open_rootfs/src/recovery.rs @@ -1,5 +1,5 @@ use crate::partitions::get_boot_partition_uuid; -use crate::proposal::read_and_verify_signed_bless_alternative_guest_os_version_proposal; +use alternative_guestos::proposal::read_and_verify_signed_bless_alternative_guest_os_version_proposal; use anyhow::{Context, Result}; use attestation::attestation_package::AttestationPackageVerifier; use attestation::custom_data::{SevCustomData, SevCustomDataNamespace}; @@ -39,6 +39,8 @@ pub fn extract_and_verify_recovery_rootfs_hash( #[cfg(any(feature = "dev", test))] let nns_public_key_override = get_nns_public_key_override(config_mount.mount_point())?; + #[cfg(not(any(feature = "dev", test)))] + let nns_public_key_override: Option> = None; drop(config_mount); @@ -52,7 +54,6 @@ pub fn extract_and_verify_recovery_rootfs_hash( let proposal = read_and_verify_signed_bless_alternative_guest_os_version_proposal( &boot_mount.mount_point().join(RECOVERY_PROPOSAL_FILE_NAME), - #[cfg(any(feature = "dev", test))] nns_public_key_override.as_deref(), )?;