diff --git a/.github/workflows/composable-python-layer-smoke.yml b/.github/workflows/composable-python-layer-smoke.yml index 1c31abdcae..2079a96634 100644 --- a/.github/workflows/composable-python-layer-smoke.yml +++ b/.github/workflows/composable-python-layer-smoke.yml @@ -7,11 +7,17 @@ on: - scripts/build-python-runtime-layer.py - scripts/test_build_python_runtime_layer.py - crates/mvm-build/src/runtime_layer.rs + - crates/mvm-build/src/pr_test_synthetic_base.rs + - crates/mvm-cli/src/commands/vm/exec.rs - crates/mvm-cli/src/commands/vm/exec/** - crates/mvm-hostd/src/plan_admission/composition.rs - crates/mvm-contract/src/plan/bundle.rs - crates/mvm-core/src/registry_pack/runtime_layer.rs - crates/mvm-core/examples/verify-runtime-layer-fixture.rs + - crates/mvm-build/src/guest_runtime/** + - crates/mvm-build/src/guest_runtime.rs + - crates/mvm-client/src/portable_boot_assets.rs + - crates/mvm-runtime/src/runtime_evidence.rs workflow_dispatch: permissions: @@ -19,6 +25,91 @@ permissions: id-token: write jobs: + # Uses the ordinary unsigned guest builder, not a release signer. Signing + # happens only in the PR job below and nothing is published as a CLI release. + pr-guest-bins: + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + uses: ./.github/workflows/guest-bins.yml + permissions: + contents: read + + signed-pr-guest-runtime: + needs: pr-guest-bins + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + actions: read + id-token: write + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@1.97.1 + - uses: sigstore/cosign-installer@v3 + - uses: actions/download-artifact@v8 + with: + name: mvm-guest-bins-attempt-${{ github.run_attempt }} + path: ${{ runner.temp }}/pr-runtime + - name: Sign and verify PR-only guest runtime inputs + env: + MVM_PR_TEST_RUNTIME_DIR: ${{ runner.temp }}/pr-runtime + MVM_PR_TEST_PULL_REQUEST: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + version="$(python3 -c 'import tomllib; print(tomllib.load(open("Cargo.toml", "rb"))["workspace"]["package"]["version"])')" + archive="$MVM_PR_TEST_RUNTIME_DIR/mvm-guest-bins-v${version}.tar.gz" + test -f "$archive" && test ! -L "$archive" + test -f "${archive}.sha256" && test ! -L "${archive}.sha256" + (cd "$MVM_PR_TEST_RUNTIME_DIR" && sha256sum --check "$(basename "${archive}.sha256")") + for blob in "$archive" "${archive}.sha256"; do + cosign sign-blob --yes --new-bundle-format --bundle "${blob}.bundle" "$blob" + done + cargo test --locked -p mvm-build --features pr-test-guest-runtime --lib \ + guest_runtime::evidence::tests::signed_pr_runtime_authenticates_exact_identity_and_refuses_tampering \ + -- --ignored --exact + cargo test --locked -p mvm-client --features pr-test-guest-runtime --lib \ + portable_boot_assets::tests::pr_test_local_acquisition_checks_assembled_runtime_independently \ + -- --ignored --exact + - name: Retain synthetic PR quartet for a builder-VM CLI witness + uses: actions/upload-artifact@v7 + with: + name: pr-test-guest-runtime-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/pr-runtime/mvm-guest-bins-v*.tar.gz* + if-no-files-found: error + retention-days: 1 + + signed-pr-base-input: + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@1.97.1 + - uses: sigstore/cosign-installer@v3 + - name: Authenticate only the PR's nonbootable synthetic base input + env: + MVM_EMBED: "0" + MVM_PR_TEST_BASE_DIR: ${{ runner.temp }}/pr-base + MVM_PR_TEST_PULL_REQUEST: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + cargo test --locked -p mvm-build --features pr-test-synthetic-base --lib \ + pr_test_synthetic_base::tests::write_synthetic_pr_base_fixture_for_ci \ + -- --ignored --exact + cosign sign-blob --yes --new-bundle-format \ + --bundle "$MVM_PR_TEST_BASE_DIR/image-set.bundle" \ + "$MVM_PR_TEST_BASE_DIR/image-set.json" + cargo test --locked -p mvm-build --features pr-test-synthetic-base --lib \ + pr_test_synthetic_base::tests::real_pr_signature_accepts_only_unchanged_exact_identity \ + -- --ignored --exact + signed-input: if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest diff --git a/Cargo.toml b/Cargo.toml index b2f2752b7b..df5120916c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -573,6 +573,7 @@ template-registry-s3 = [ # in-process) needs it turned on. `mvm-runtime` is named directly too since # this crate depends on it straight, not only through `mvm-cli`. test-support = ["mvm-cli/test-support", "mvm-runtime/test-support"] +pr-test-guest-runtime = ["mvm-cli/pr-test-guest-runtime"] # Debug-only Apple Silicon warm-restore validation. The runtime additionally # requires a debug build on macOS/aarch64 before advertising the capability. hvf-live-validation = [ diff --git a/crates/mvm-build/Cargo.toml b/crates/mvm-build/Cargo.toml index 89d8a3c061..f00ddc95de 100644 --- a/crates/mvm-build/Cargo.toml +++ b/crates/mvm-build/Cargo.toml @@ -141,6 +141,10 @@ release-channel = [] # `builder_pack`'s tests can exercise `verify_pack_keyless_at` without # every mvm-build build pulling the sigstore dependency tree. manifest-verify = ["mvm-core/manifest-verify"] +# PR-only signed runtime inputs; never part of the release channel. +pr-test-guest-runtime = ["manifest-verify", "mvm-core/pr-test-guest-runtime"] +# Authenticate a synthetic PR base only as a local development input. +pr-test-synthetic-base = ["manifest-verify"] # Exposes `published_image_set::fixture`, a signed-root-shaped image set served # from a directory, to other crates' tests. test-support = ["mvm-fs/test-support"] diff --git a/crates/mvm-build/src/guest_runtime.rs b/crates/mvm-build/src/guest_runtime.rs index ec9bbee567..bcbf24adf2 100644 --- a/crates/mvm-build/src/guest_runtime.rs +++ b/crates/mvm-build/src/guest_runtime.rs @@ -182,11 +182,39 @@ pub fn install_signed_guest_runtime_archive( arch: GuestArch, ) -> Result { let verified = evidence::verify_release_archive(archive, checksum, bundle, version, arch)?; + let mut runtime = install_verified_archive(cache_root, &verified, arch)?; + runtime.release_proof = Some(evidence::RuntimeReleaseProof { + archive: archive.canonicalize()?, + checksum: checksum.canonicalize()?, + signature_bundle: bundle.canonicalize()?, + archive_sha256: verified.archive_sha256().to_string(), + version: version.to_string(), + arch, + }); + Ok(runtime) +} + +/// Install a PR-signed test archive; never mint release provenance. +#[cfg(feature = "pr-test-guest-runtime")] +pub fn install_pr_test_guest_runtime_archive( + cache_root: &Path, + proof: &mvm_core::protocol::runtime_release::PrTestRuntimeProof, +) -> Result { + let verified = + evidence::verify_pr_test_runtime_proof(proof, &proof.input.version, proof.input.arch)?; + install_verified_archive(cache_root, &verified, proof.input.arch) +} + +fn install_verified_archive( + cache_root: &Path, + verified: &evidence::VerifiedRuntimeManifest, + arch: GuestArch, +) -> Result { let base = cache_root.join("guest-runtime").join("v1"); fs::create_dir_all(&base)?; let _lock = guest_agent_build::acquire_guest_build_lock(&base, "guest runtime")?; let stage = stage_archive(&base, verified.archive_path())?; - let mut runtime = promote_staged_archive( + let runtime = promote_staged_archive( &base, stage, verified.manifest().clone(), @@ -194,14 +222,6 @@ pub fn install_signed_guest_runtime_archive( arch, ArchiveScope::Release, )?; - runtime.release_proof = Some(evidence::RuntimeReleaseProof { - archive: archive.canonicalize()?, - checksum: checksum.canonicalize()?, - signature_bundle: bundle.canonicalize()?, - archive_sha256: verified.archive_sha256().to_string(), - version: version.to_string(), - arch, - }); Ok(runtime) } diff --git a/crates/mvm-build/src/guest_runtime/evidence.rs b/crates/mvm-build/src/guest_runtime/evidence.rs index b27da5b646..01c564ffb4 100644 --- a/crates/mvm-build/src/guest_runtime/evidence.rs +++ b/crates/mvm-build/src/guest_runtime/evidence.rs @@ -349,12 +349,73 @@ pub fn verify_runtime_release_proof( Ok(verified) } +/// Authenticate PR-only runtime bytes, without representing them as a release. +#[cfg(feature = "pr-test-guest-runtime")] +pub fn verify_pr_test_runtime_proof( + proof: &mvm_core::protocol::runtime_release::PrTestRuntimeProof, + expected_version: &str, + expected_arch: GuestArch, +) -> Result { + if !crate::artifact_acquisition::compiled_channel().permits_automatic_builds() + || proof.pull_request == 0 + || proof.input.version != expected_version + || proof.input.arch != expected_arch + || !super::is_archive_digest(&proof.input.archive_sha256) + { + return Err(GuestRuntimeError::Cache( + "PR test runtime selection does not match this CLI".into(), + )); + } + let identity = format!( + "https://github.com/tinylabscom/mvm/.github/workflows/composable-python-layer-smoke.yml@refs/pull/{}/merge", + proof.pull_request + ); + let verified = verify_archive_with_identities( + &proof.input.archive, + &proof.input.checksum, + &proof.input.signature_bundle, + expected_version, + expected_arch, + &[identity], + true, + )?; + if verified.archive_sha256() != proof.input.archive_sha256 { + return Err(GuestRuntimeError::Cache( + "PR test runtime archive digest changed".into(), + )); + } + verified + .declaration(expected_arch)? + .validate(expected_arch)?; + Ok(verified) +} + pub(super) fn verify_release_archive( archive: &Path, checksum: &Path, bundle: &Path, version: &str, arch: GuestArch, +) -> Result { + verify_archive_with_identities( + archive, + checksum, + bundle, + version, + arch, + &mvm_core::release_trust::accepted_release_identities(version), + false, + ) +} + +fn verify_archive_with_identities( + archive: &Path, + checksum: &Path, + bundle: &Path, + version: &str, + arch: GuestArch, + identities: &[String], + require_checksum_signature: bool, ) -> Result { let asset = crate::guest_bins::guest_bins_archive_name(version); let bytes = std::fs::read(archive)?; @@ -366,8 +427,17 @@ pub(super) fn verify_release_archive( // still verify; portable acquisition separately requires that fourth file. let checksum_signature = if checksum_bundle_present(&checksum_bundle)? { let signature = std::fs::read(&checksum_bundle)?; - verify_checksum_signature_bytes(&checksum_bytes, &signature, version)?; + verify_checksum_signature_bytes_for_identities( + &checksum_bytes, + &signature, + version, + identities, + )?; Some(signature) + } else if require_checksum_signature { + return Err(GuestRuntimeError::Cache( + "PR test runtime checksum signature is missing".into(), + )); } else { None }; @@ -385,7 +455,7 @@ pub(super) fn verify_release_archive( &bytes, &signature, &asset, - &mvm_core::release_trust::accepted_release_identities(version), + identities, mvm_core::release_trust::RELEASE_OIDC_ISSUER, )?; let mut snapshot = tempfile::NamedTempFile::new()?; @@ -434,6 +504,20 @@ fn verify_checksum_signature_bytes( checksum_bytes: &[u8], bundle: &[u8], version: &str, +) -> Result<(), GuestRuntimeError> { + verify_checksum_signature_bytes_for_identities( + checksum_bytes, + bundle, + version, + &mvm_core::release_trust::accepted_release_identities(version), + ) +} + +fn verify_checksum_signature_bytes_for_identities( + checksum_bytes: &[u8], + bundle: &[u8], + version: &str, + identities: &[String], ) -> Result<(), GuestRuntimeError> { crate::release_signature::verify_release_archive_bytes( checksum_bytes, @@ -442,7 +526,7 @@ fn verify_checksum_signature_bytes( "{}.sha256", crate::guest_bins::guest_bins_archive_name(version) ), - &mvm_core::release_trust::accepted_release_identities(version), + identities, mvm_core::release_trust::RELEASE_OIDC_ISSUER, )?; Ok(()) @@ -622,6 +706,131 @@ mod tests { (dir, verified) } + #[cfg(all(feature = "pr-test-guest-runtime", not(feature = "release-channel")))] + #[test] + #[ignore = "requires a real keyless-signed archive and checksum from the PR smoke workflow"] + fn signed_pr_runtime_authenticates_exact_identity_and_refuses_tampering() { + use mvm_core::protocol::runtime_release::PrTestRuntimeProof; + let directory = PathBuf::from( + std::env::var_os("MVM_PR_TEST_RUNTIME_DIR").expect("PR smoke fixture directory"), + ); + let pull_request: u64 = std::env::var("MVM_PR_TEST_PULL_REQUEST") + .expect("PR smoke fixture number") + .parse() + .expect("numeric PR number"); + let version = env!("CARGO_PKG_VERSION"); + let arch = GuestArch::X86_64; + let asset = crate::guest_bins::guest_bins_archive_name(version); + let archive = directory.join(&asset); + let input = RuntimeReleaseProof { + archive: archive.clone(), + checksum: directory.join(format!("{asset}.sha256")), + signature_bundle: directory.join(format!("{asset}.bundle")), + archive_sha256: hex::encode(Sha256::digest(std::fs::read(&archive).unwrap())), + version: version.into(), + arch, + }; + let mut proof = PrTestRuntimeProof { + input, + pull_request, + }; + let verified = verify_pr_test_runtime_proof(&proof, version, arch) + .expect("exact GitHub PR workflow identity signs both archive and checksum"); + assert_eq!(verified.archive_sha256(), proof.input.archive_sha256); + assert!( + verify_runtime_release_proof(&proof.input, version, arch).is_err(), + "PR workflow signature never impersonates release identity" + ); + proof.pull_request += 1; + assert!( + verify_pr_test_runtime_proof(&proof, version, arch).is_err(), + "another PR identity cannot authorize the archive" + ); + proof.pull_request -= 1; + let copy = tempfile::tempdir().unwrap(); + for (source, name) in [ + (&proof.input.archive, asset.as_str()), + (&proof.input.checksum, "checksum"), + (&proof.input.signature_bundle, "archive.bundle"), + ( + &checksum_bundle_path(&proof.input.checksum), + "checksum.bundle", + ), + ] { + std::fs::copy(source, copy.path().join(name)).unwrap(); + } + proof.input.archive = copy.path().join(&asset); + proof.input.checksum = copy.path().join("checksum"); + proof.input.signature_bundle = copy.path().join("archive.bundle"); + std::fs::write(&proof.input.archive, b"tampered").unwrap(); + assert!(verify_pr_test_runtime_proof(&proof, version, arch).is_err()); + std::fs::copy(&archive, &proof.input.archive).unwrap(); + std::fs::write(&proof.input.checksum, b"tampered").unwrap(); + assert!(verify_pr_test_runtime_proof(&proof, version, arch).is_err()); + } + + #[cfg(all(feature = "pr-test-guest-runtime", not(feature = "release-channel")))] + #[test] + fn pr_test_runtime_requires_nonzero_pr_and_signed_checksum() { + use mvm_core::protocol::runtime_release::PrTestRuntimeProof; + let directory = tempfile::tempdir().unwrap(); + let version = "1.2.3"; + let arch = GuestArch::X86_64; + let asset = crate::guest_bins::guest_bins_archive_name(version); + let archive = directory.path().join(&asset); + let checksum = directory.path().join(format!("{asset}.sha256")); + std::fs::write(&archive, b"unsigned archive").unwrap(); + let digest = hex::encode(Sha256::digest(b"unsigned archive")); + std::fs::write(&checksum, format!("{digest} {asset}\n")).unwrap(); + let mut proof = PrTestRuntimeProof { + input: RuntimeReleaseProof { + archive, + checksum, + signature_bundle: directory.path().join(format!("{asset}.bundle")), + archive_sha256: digest, + version: version.into(), + arch, + }, + pull_request: 0, + }; + assert!( + verify_pr_test_runtime_proof(&proof, version, arch) + .unwrap_err() + .to_string() + .contains("selection") + ); + proof.pull_request = 42; + assert!( + verify_pr_test_runtime_proof(&proof, version, arch) + .unwrap_err() + .to_string() + .contains("checksum signature") + ); + } + + #[cfg(all(feature = "pr-test-guest-runtime", feature = "release-channel"))] + #[test] + fn release_channel_refuses_pr_runtime_without_reading_input() { + let proof = mvm_core::protocol::runtime_release::PrTestRuntimeProof { + input: RuntimeReleaseProof { + archive: "/missing/pr/archive".into(), + checksum: "/missing/pr/checksum".into(), + signature_bundle: "/missing/pr/bundle".into(), + archive_sha256: "a".repeat(64), + version: "1.2.3".into(), + arch: GuestArch::host(), + }, + pull_request: 42, + }; + assert!( + verify_pr_test_runtime_proof(&proof, "1.2.3", GuestArch::host()) + .unwrap_err() + .to_string() + .contains("PR test runtime selection"), + "a release-channel build cannot opt into PR-test runtime evidence" + ); + } + fn selection<'a>(paths: &'a [PathBuf; 3], root: &'a str) -> RuntimeArtifactSelection<'a> { RuntimeArtifactSelection { arch: GuestArch::X86_64, diff --git a/crates/mvm-build/src/image_source/build.rs b/crates/mvm-build/src/image_source/build.rs index 6fa04e6d89..70dc9f17f7 100644 --- a/crates/mvm-build/src/image_source/build.rs +++ b/crates/mvm-build/src/image_source/build.rs @@ -134,6 +134,7 @@ const DEFAULT_TENANT: TargetContract = TargetContract { files: &[ kernel("vmlinux", "default_tenant_workload_kernel"), file("rootfs.ext4", "default_tenant_workload_rootfs", "ext4"), + file("rootfs.tar.gz", "default_tenant_workload_rootfs", "tar_gz"), file( "rootfs.verity", "default_tenant_workload_rootfs", @@ -181,6 +182,7 @@ const ROOTLESS_TENANT: TargetContract = TargetContract { files: &[ kernel("vmlinux", "rootless_tenant_workload_kernel"), file("rootfs.ext4", "rootless_tenant_workload_rootfs", "ext4"), + file("rootfs.tar.gz", "rootless_tenant_workload_rootfs", "tar_gz"), file( "rootfs.verity", "rootless_tenant_workload_rootfs", @@ -1077,6 +1079,36 @@ mod tests { } } + #[test] + fn sealed_local_bases_carry_the_composition_tar_but_dev_roots_do_not() { + for role in [ + ImageBuildRole::DefaultTenant, + ImageBuildRole::RootlessTenant, + ] { + let files = contract_for(&target(role, "default")).unwrap().files; + assert!( + files.iter().any(|output| *output + == file( + "rootfs.tar.gz", + if role == ImageBuildRole::DefaultTenant { + "default_tenant_workload_rootfs" + } else { + "rootless_tenant_workload_rootfs" + }, + "tar_gz" + )), + "{role} sealed output must bind the exact tar" + ); + } + let dev = contract_for(&target(ImageBuildRole::DefaultTenant, "dev")) + .unwrap() + .files; + assert!( + dev.iter().all(|output| output.name != "rootfs.tar.gz"), + "unsealed dev output must not imply a composition tar" + ); + } + /// The builder-vm set is installed into the builder-VM cache, which /// requires every name in `BUILDER_VM_CACHE_ARTIFACTS`. A name the /// contract leaves out is never copied out of the build, so the install diff --git a/crates/mvm-build/src/lib.rs b/crates/mvm-build/src/lib.rs index 039a66a902..9d3f1ff542 100644 --- a/crates/mvm-build/src/lib.rs +++ b/crates/mvm-build/src/lib.rs @@ -4,6 +4,9 @@ //! Non-Unix hosts can observe repositories and produce/verify local manifests. //! They do not expose a VM launcher or a fallback implementation of one. +#[cfg(all(feature = "pr-test-synthetic-base", feature = "release-channel"))] +compile_error!("pr-test-synthetic-base is unavailable with release-channel"); + pub mod artifact_acquisition; pub mod base_image_contract; pub mod build_cache; @@ -16,6 +19,8 @@ pub mod embed_toolchain; pub use mvm_builder_guest::host_payload_manifest; pub mod image_protocol; pub mod image_source; +#[cfg(all(unix, feature = "pr-test-synthetic-base"))] +pub mod pr_test_synthetic_base; pub mod source_closure; mod source_copy; pub mod workspace_graph; diff --git a/crates/mvm-build/src/pr_test_synthetic_base.rs b/crates/mvm-build/src/pr_test_synthetic_base.rs new file mode 100644 index 0000000000..56f03c64bf --- /dev/null +++ b/crates/mvm-build/src/pr_test_synthetic_base.rs @@ -0,0 +1,358 @@ +//! PR-signed synthetic Linux inputs, admitted only through local development composition. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, ensure}; +use mvm_core::arch::GuestArch; +use mvm_core::image_set::{ImageSetManifest, WorkloadImageProfile}; + +const ISSUER: &str = "https://token.actions.githubusercontent.com"; +const MAX_BUNDLE_BYTES: u64 = 1024 * 1024; + +/// A fixture selection, not a release image-set proof or a boot capability. +#[derive(Debug)] +pub struct PrTestSyntheticBase { + directory: PathBuf, + pull_request: u64, +} + +/// Paths and exact manifest bytes checked against the PR workflow signature. +#[derive(Debug)] +pub struct VerifiedSyntheticBase { + pub manifest: Vec, + pub manifest_path: PathBuf, + pub kernel: PathBuf, + pub tar: PathBuf, +} + +impl PrTestSyntheticBase { + /// PR number pinned to the exact workflow signing identity. + pub fn pull_request(&self) -> u64 { + self.pull_request + } + + /// Select a private PR fixture; verification is repeated before every use. + /// + /// # Errors + /// Refuses an absent PR number or a non-directory/symlink fixture. + pub fn new(directory: &Path, pull_request: u64) -> Result { + ensure!( + pull_request > 0, + "synthetic base requires a positive PR number" + ); + ensure!( + std::fs::symlink_metadata(directory)?.file_type().is_dir(), + "synthetic base fixture must be a real directory" + ); + Ok(Self { + directory: std::fs::canonicalize(directory)?, + pull_request, + }) + } + + /// Authenticate the exact local schema-3 root and selected regular kernel/tar. + /// + /// # Errors + /// Refuses changed bytes, the wrong PR signer, an invalid set or incompatible inputs. + pub fn verify(&self, arch: GuestArch) -> Result { + self.verify_with(arch, |manifest, bundle, identity| { + mvm_core::crypto::image_verify::verify_signed_payload( + manifest, bundle, identity, ISSUER, + ) + .map_err(Into::into) + }) + } + + fn verify_with( + &self, + arch: GuestArch, + verify_signature: impl FnOnce(&[u8], &[u8], &str) -> Result<()>, + ) -> Result { + let manifest_path = self.directory.join("image-set.json"); + let bundle_path = self.directory.join("image-set.bundle"); + let manifest = bounded_regular( + &manifest_path, + mvm_core::plan::bundle::MAX_IMAGE_SET_MANIFEST_BYTES, + )?; + let bundle = bounded_regular(&bundle_path, MAX_BUNDLE_BYTES)?; + let identity = format!( + "https://github.com/tinylabscom/mvm/.github/workflows/composable-python-layer-smoke.yml@refs/pull/{}/merge", + self.pull_request + ); + verify_signature(&manifest, &bundle, &identity) + .context("synthetic base manifest is not signed by the exact PR workflow")?; + let declared: ImageSetManifest = + serde_json::from_slice(&manifest).context("parse signed synthetic base manifest")?; + ensure!( + declared.schema_version == mvm_core::image_set::IMAGE_SET_SCHEMA_VERSION, + "synthetic base requires schema 3" + ); + mvm_core::image_set::check_workload_protocol_compatibility( + &declared.compatibility, + &crate::stage0_kernel::current_image_set_protocol_support(), + )?; + let (kernel, tar) = mvm_core::plan::bundle::select_local_development_source( + &declared, + arch, + WorkloadImageProfile::DefaultTenant, + )?; + let selected = [(kernel, 256 * 1024 * 1024), (tar, 2 * 1024 * 1024 * 1024)]; + let mut paths = Vec::with_capacity(2); + for (artifact, maximum) in selected { + ensure!( + artifact.size <= maximum, + "synthetic base artifact exceeds capture bound" + ); + let path = self.directory.join(artifact.name.as_str()); + let metadata = std::fs::symlink_metadata(&path)?; + ensure!( + metadata.file_type().is_file() && metadata.len() == artifact.size, + "synthetic base artifact is missing, linked or changed" + ); + ensure!( + mvm_core::crypto::image_verify::sha256_file(&path)? == artifact.sha256.as_str(), + "synthetic base artifact digest changed" + ); + paths.push(path); + } + let [kernel, tar] = paths + .try_into() + .map_err(|_| anyhow::anyhow!("expected kernel and tar"))?; + Ok(VerifiedSyntheticBase { + manifest, + manifest_path, + kernel, + tar, + }) + } +} + +fn bounded_regular(path: &Path, max: u64) -> Result> { + let metadata = std::fs::symlink_metadata(path) + .with_context(|| format!("synthetic base input missing: {}", path.display()))?; + ensure!( + metadata.file_type().is_file() && metadata.len() <= max, + "synthetic base input must be a bounded regular file" + ); + let bytes = std::fs::read(path)?; + ensure!( + bytes.len() as u64 <= max, + "synthetic base input grew while reading" + ); + Ok(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::published_image_set::fixture::ImageSetFixture; + use mvm_core::image_set::{ + ArtifactFormat, BaseImageContract, GuestDeviceRequirement, ImageSetRole, MemberArtifact, + MemberTarget, + }; + use mvm_core::packs::Sha256Hex; + + fn local_fixture(dir: &Path) -> ImageSetManifest { + let arch = GuestArch::host(); + let profile = WorkloadImageProfile::DefaultTenant; + let target = MemberTarget::Arch(arch); + let kernel = b"synthetic kernel"; + let tar = b"synthetic tar"; + let mut manifest = ImageSetFixture::complete() + .with_base_image_contract(Some(BaseImageContract::LINUX_LAYER)) + .publish( + ImageSetRole::WorkloadKernel(profile), + target, + "kernel", + kernel.to_vec(), + ) + .publish( + ImageSetRole::WorkloadRootfs(profile), + target, + "rootfs.ext4", + b"ext4".to_vec(), + ) + .into_manifest(); + manifest.members.retain(|member| { + member.target == target + && matches!( + member.role, + ImageSetRole::WorkloadKernel(_) | ImageSetRole::WorkloadRootfs(_) + ) + }); + manifest.producer = serde_json::from_value(serde_json::json!({ + "local_checkouts": { + "images": { + "commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "worktree": {"state": "clean"} + } + } + })) + .unwrap(); + manifest.revocation_channel = None; + manifest.mvm_source_commit = None; + for member in &mut manifest.members { + member.pack_hash = None; + member.sbom = None; + match member.role { + ImageSetRole::WorkloadKernel(_) => { + member.required_capabilities = vec![GuestDeviceRequirement::VirtioVsock]; + } + ImageSetRole::WorkloadRootfs(_) => { + member.required_capabilities = vec![GuestDeviceRequirement::VirtioBlk]; + member.artifacts.push(MemberArtifact { + name: mvm_core::image_set::ArtifactName::new("rootfs.tar.gz").unwrap(), + format: ArtifactFormat::TarGz, + sha256: Sha256Hex::from_bytes(tar), + size: tar.len() as u64, + }); + } + _ => unreachable!("only selected workload members are retained"), + } + } + std::fs::write(dir.join("kernel"), kernel).unwrap(); + std::fs::write(dir.join("rootfs.tar.gz"), tar).unwrap(); + std::fs::write( + dir.join("image-set.json"), + serde_json::to_vec(&manifest).unwrap(), + ) + .unwrap(); + std::fs::write(dir.join("image-set.bundle"), b"signed bundle").unwrap(); + manifest + } + + #[test] + fn exact_pr_identity_and_selected_bytes_are_required() { + let dir = tempfile::tempdir().unwrap(); + let mut manifest = local_fixture(dir.path()); + let release_producer = ImageSetFixture::complete().into_manifest().producer; + let fixture = PrTestSyntheticBase::new(dir.path(), 42).unwrap(); + let verify = || { + fixture.verify_with(GuestArch::host(), |bytes, bundle, identity| { + assert_eq!(bytes, std::fs::read(dir.path().join("image-set.json")).unwrap()); + assert_eq!(bundle, b"signed bundle"); + assert_eq!( + identity, + "https://github.com/tinylabscom/mvm/.github/workflows/composable-python-layer-smoke.yml@refs/pull/42/merge" + ); + Ok(()) + }) + }; + assert!(verify().is_ok()); + let foreign_arch = match GuestArch::host() { + GuestArch::X86_64 => GuestArch::Aarch64, + GuestArch::Aarch64 => GuestArch::X86_64, + }; + assert!(fixture.verify_with(foreign_arch, |_, _, _| Ok(())).is_err()); + std::fs::write(dir.path().join("kernel"), b"tampered kernel").unwrap(); + assert!(verify().is_err()); + std::fs::write(dir.path().join("kernel"), b"synthetic kernel").unwrap(); + std::fs::write(dir.path().join("rootfs.tar.gz"), b"changed tar").unwrap(); + assert!(verify().is_err()); + std::fs::write(dir.path().join("rootfs.tar.gz"), b"synthetic tar").unwrap(); + manifest.schema_version = 2; + std::fs::write( + dir.path().join("image-set.json"), + serde_json::to_vec(&manifest).unwrap(), + ) + .unwrap(); + assert!(verify().is_err()); + manifest.schema_version = mvm_core::image_set::IMAGE_SET_SCHEMA_VERSION; + manifest.producer = release_producer; + std::fs::write( + dir.path().join("image-set.json"), + serde_json::to_vec(&manifest).unwrap(), + ) + .unwrap(); + assert!( + verify().is_err(), + "release producer cannot become a PR-local base" + ); + } + + #[test] + fn absent_partial_or_unsigned_fixture_never_falls_back() { + let dir = tempfile::tempdir().unwrap(); + assert!(PrTestSyntheticBase::new(dir.path(), 0).is_err()); + let fixture = PrTestSyntheticBase::new(dir.path(), 42).unwrap(); + assert!(fixture.verify(GuestArch::host()).is_err()); + std::fs::write(dir.path().join("image-set.json"), b"{}").unwrap(); + std::fs::write(dir.path().join("image-set.bundle"), b"{}").unwrap(); + assert!(fixture.verify(GuestArch::host()).is_err()); + std::fs::remove_file(dir.path().join("image-set.bundle")).unwrap(); + assert!(fixture.verify(GuestArch::host()).is_err()); + } + + #[cfg(unix)] + #[test] + fn linked_manifest_is_refused() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("real"), b"{}").unwrap(); + std::os::unix::fs::symlink("real", dir.path().join("image-set.json")).unwrap(); + assert!( + PrTestSyntheticBase::new(dir.path(), 42) + .unwrap() + .verify(GuestArch::host()) + .is_err() + ); + } + + #[test] + #[ignore = "writes nonbootable synthetic fixture to MVM_PR_TEST_BASE_DIR for PR signing"] + fn write_synthetic_pr_base_fixture_for_ci() { + let path = PathBuf::from(std::env::var_os("MVM_PR_TEST_BASE_DIR").unwrap()); + std::fs::create_dir(&path).unwrap(); + local_fixture(&path); + } + + #[test] + #[ignore = "requires a real PR-workflow cosign bundle in MVM_PR_TEST_BASE_DIR"] + fn real_pr_signature_accepts_only_unchanged_exact_identity() { + let path = PathBuf::from(std::env::var_os("MVM_PR_TEST_BASE_DIR").unwrap()); + let number: u64 = std::env::var("MVM_PR_TEST_PULL_REQUEST") + .unwrap() + .parse() + .unwrap(); + let arch = GuestArch::host(); + let base = PrTestSyntheticBase::new(&path, number).unwrap(); + let checked = base + .verify(arch) + .expect("same-PR fixture must authenticate"); + assert!( + PrTestSyntheticBase::new(&path, number + 1) + .unwrap() + .verify(arch) + .is_err(), + "another PR must not authenticate the fixture" + ); + + let copied = tempfile::tempdir().unwrap(); + let bundle = path.join("image-set.bundle"); + for input in [ + checked.manifest_path.as_path(), + bundle.as_path(), + checked.kernel.as_path(), + checked.tar.as_path(), + ] { + std::fs::copy(input, copied.path().join(input.file_name().unwrap())).unwrap(); + } + let local = PrTestSyntheticBase::new(copied.path(), number).unwrap(); + assert!(local.verify(arch).is_ok()); + std::fs::write(copied.path().join("image-set.json"), b"{}").unwrap(); + assert!(local.verify(arch).is_err(), "changed manifest must fail"); + std::fs::write(copied.path().join("image-set.json"), &checked.manifest).unwrap(); + std::fs::write( + copied.path().join(checked.kernel.file_name().unwrap()), + b"changed", + ) + .unwrap(); + assert!(local.verify(arch).is_err(), "changed kernel must fail"); + std::fs::copy( + &checked.kernel, + copied.path().join(checked.kernel.file_name().unwrap()), + ) + .unwrap(); + std::fs::remove_file(copied.path().join(checked.tar.file_name().unwrap())).unwrap(); + assert!(local.verify(arch).is_err(), "missing tar must fail"); + } +} diff --git a/crates/mvm-build/src/published_image_set.rs b/crates/mvm-build/src/published_image_set.rs index e8bce5c95a..e6960f294d 100644 --- a/crates/mvm-build/src/published_image_set.rs +++ b/crates/mvm-build/src/published_image_set.rs @@ -360,6 +360,99 @@ impl PublishedImageSet { }) } + /// Verify a separately signed production base descriptor against this set. + /// + /// `artifact_dir` must contain the selected production kernel and composition + /// tar under their exact manifest names. No other set members are read or + /// fetched. The retained result refers to these files; keep them for rechecks. + /// Local or host-signed bases cannot satisfy the locked publisher identity. + /// + /// # Errors + /// Refuses missing or changed files, an unsigned or incompatible descriptor, + /// or missing, stale, or revoked publisher evidence. + pub fn verify_base_compatibility( + &self, + descriptor: &[u8], + signature_bundle: &[u8], + arch: GuestArch, + profile: WorkloadImageProfile, + artifact_dir: &Path, + ) -> Result { + self.with_base_verification(arch, profile, artifact_dir, |request| { + mvm_core::image_set::base_compatibility::verify_production_base_compatibility( + descriptor, + signature_bundle, + request, + arch, + profile, + ) + .map_err(Into::into) + }) + } + + /// Reverify held descriptor evidence against the original signed root and current feed. + /// + /// `artifact_dir` must still contain the selected kernel and tar under their + /// manifest names. The returned evidence replaces the earlier verification. + /// + /// # Errors + /// Refuses changed files, signatures, or expired or revoked evidence. + pub fn reverify_base_compatibility( + &self, + verified: &mvm_core::image_set::base_compatibility::ProductionBaseCompatibility, + artifact_dir: &Path, + ) -> Result { + self.with_base_verification( + verified.descriptor().arch(), + verified.descriptor().profile(), + artifact_dir, + |request| verified.reverify(request).map_err(Into::into), + ) + } + + fn with_base_verification( + &self, + arch: GuestArch, + profile: WorkloadImageProfile, + artifact_dir: &Path, + verify: impl FnOnce(&mvm_core::image_set::ImageSetVerification<'_>) -> Result, + ) -> Result { + anyhow::ensure!( + self.manifest.schema_version == mvm_core::image_set::IMAGE_SET_SCHEMA_VERSION + && self.manifest.compatibility.base_image_contract + == Some(mvm_core::image_set::BaseImageContract::LINUX_LAYER), + "production base compatibility requires a signed Linux-layer image set" + ); + anyhow::ensure!( + self.manifest.members.iter().any(|member| { + member.build_mode.is_none() + && member.role == ImageSetRole::WorkloadRootfs(profile) + && member.target == MemberTarget::Arch(arch) + && member.artifacts.iter().any(|artifact| { + artifact.format == mvm_core::image_set::ArtifactFormat::TarGz + }) + }), + "production base compatibility requires a signed composition tar" + ); + let proof = self.retained_publisher_proof()?; + let requirement = ImageSetRequirement::current_train(); + let host = crate::stage0_kernel::current_image_set_protocol_support(); + let request = mvm_core::image_set::ImageSetVerification::new( + proof.root().manifest_bytes(), + proof.root().signature_bundle(), + &mvm_core::image_set::image_train_lock().image_set, + artifact_dir, + ) + .require(&requirement) + .with_revocations(proof.revocations().verified()); + let request = if self.workload_only { + request.with_workload_protocols(&host) + } else { + request.with_host_protocols(&host) + }; + verify(&request) + } + pub fn member_cache(&self) -> SetMemberCache { SetMemberCache::for_root(self.root.clone()) } diff --git a/crates/mvm-build/src/published_image_set/tests.rs b/crates/mvm-build/src/published_image_set/tests.rs index b708b47536..b445be5864 100644 --- a/crates/mvm-build/src/published_image_set/tests.rs +++ b/crates/mvm-build/src/published_image_set/tests.rs @@ -114,6 +114,76 @@ fn portable_publisher_proof_requires_current_feed_even_for_fixture_acquisition() assert!(!mvm_core::image_set::image_set_root_proof_cache().exists()); } +#[test] +fn production_base_verification_refuses_legacy_and_tarless_sets() { + let mut env = unsigned_env(); + let home = tempfile::tempdir().unwrap(); + env.isolate_mvm_home(home.path()); + let served = tempfile::tempdir().unwrap(); + let selected = tempfile::tempdir().unwrap(); + let role = ImageSetRole::WorkloadRootfs(WorkloadImageProfile::DefaultTenant); + let target = MemberTarget::Arch(ARCH); + for (fixture, reason) in [ + (ImageSetFixture::complete(), "signed Linux-layer image set"), + ( + ImageSetFixture::complete() + .with_base_image_contract(Some(mvm_core::image_set::BaseImageContract::LINUX_LAYER)) + .publish(role, target, "default-rootfs.ext4", b"ext4".to_vec()), + "signed composition tar", + ), + ] { + let set = + PublishedImageSet::acquire_workload_from(fixture.serve_from(served.path())).unwrap(); + let error = set + .verify_base_compatibility( + b"{}", + b"not a signature", + ARCH, + WorkloadImageProfile::DefaultTenant, + selected.path(), + ) + .unwrap_err(); + assert!(format!("{error:#}").contains(reason), "{error:#}"); + } + assert_eq!(std::fs::read_dir(selected.path()).unwrap().count(), 0); +} + +#[test] +fn production_base_verification_requires_retained_publisher_and_checkpointed_feed() { + let mut env = unsigned_env(); + let home = tempfile::tempdir().unwrap(); + env.isolate_mvm_home(home.path()); + let served = tempfile::tempdir().unwrap(); + let selected = tempfile::tempdir().unwrap(); + let role = ImageSetRole::WorkloadRootfs(WorkloadImageProfile::DefaultTenant); + let target = MemberTarget::Arch(ARCH); + let fixture = ImageSetFixture::complete() + .with_base_image_contract(Some(mvm_core::image_set::BaseImageContract::LINUX_LAYER)) + .publish(role, target, "default-rootfs.ext4", b"ext4".to_vec()) + .publish_extra(role, target, "default-rootfs.tar.gz", b"tar".to_vec()); + for acquire in [ + PublishedImageSet::acquire_from, + PublishedImageSet::acquire_workload_from, + ] { + let set = acquire(fixture.serve_from(served.path())).unwrap(); + let error = set + .verify_base_compatibility( + b"{}", + b"not a signature", + ARCH, + WorkloadImageProfile::DefaultTenant, + selected.path(), + ) + .unwrap_err(); + assert!( + format!("{error:#}").contains("current signed image-set revocations required"), + "{error:#}" + ); + } + assert_eq!(std::fs::read_dir(selected.path()).unwrap().count(), 0); + assert!(!mvm_core::image_set::image_set_root_proof_cache().exists()); +} + #[test] fn cached_compatibility_binding_accepts_matching_authenticated_contract_shape() { let expected = ImageSetFixture::complete() diff --git a/crates/mvm-build/src/runtime_layer.rs b/crates/mvm-build/src/runtime_layer.rs index 3b471f3c39..47aa21222d 100644 --- a/crates/mvm-build/src/runtime_layer.rs +++ b/crates/mvm-build/src/runtime_layer.rs @@ -9,9 +9,18 @@ use std::path::{Path, PathBuf}; use mvm_contract::pack_spec::PackTarget; use mvm_contract::pack_spec_v2::{BuildMode, ComposablePackError, ComposablePackSpec}; +use mvm_contract::pack_spec_v3::{ComposedGraph, GraphNode, SignedPackPin}; use mvm_core::arch::GuestArch; -use mvm_core::image_set::WorkloadImageProfile; +use mvm_core::image_set::base_compatibility::{ + DevelopmentBaseCompatibility, ProductionBaseCompatibility, +}; +use mvm_core::image_set::{ + ArtifactFormat, ImageSetManifest, ImageTrustTier, LocalImageSetVerification, + WorkloadImageProfile, +}; use mvm_core::packs::Sha256Hex; +use mvm_core::registry_pack::application_component::VerifiedApplicationComponentInputs; +use mvm_core::registry_pack::component_layer::{ComponentLayerKind, VerifiedComponentLayerInputs}; use mvm_core::registry_pack::runtime_layer::{ OfflineRuntimePolicyError, RuntimeLayerArchiveError, RuntimeLayerInputError, RuntimeLayerPolicyError, RuntimeLayerResolveError, VerifiedRuntimeLayerArchive, @@ -27,6 +36,12 @@ use mvm_fs::ownership::OwnerTable; use serde::{Deserialize, Serialize}; use thiserror::Error; +mod component_lock; +pub use component_lock::{ + ComponentArchivePin, ComponentFilesystemLockSchema, MaterializedComponentRootfs, + ResolvedComponentFilesystemLock, +}; + const MAX_UNPACKED_BYTES: u64 = 4 * 1024 * 1024 * 1024; const MAX_ENTRIES: u64 = 200_000; const MAX_LAYERS: usize = 64; @@ -119,6 +134,17 @@ pub struct ComposedApplicationRootfs { application_archive_sha256: Sha256Hex, } +/// A private, authenticated ordered component tree; not a launch or export grant. +#[derive(Debug)] +pub struct ComposedComponentRootfs { + tree: ComposedWorkloadRootfs, + pins: Vec, + archives: Vec, + base_descriptor_sha256: Sha256Hex, + arch: GuestArch, + tier: ImageTrustTier, +} + /// This lock has its own schema; it never changes the v1 Nix pack lock. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum ComposableLockSchema { @@ -169,6 +195,25 @@ impl ResolvedComposableLock { } Ok(()) } + + /// Identity of filesystem bytes, excluding command and policy decisions. + /// + /// The base and runtime manifest pins bind their archive digests. The + /// application archive digest binds every copied file. A new assembler + /// revision changes the key even when its version number has not changed. + pub fn filesystem_identity(&self) -> anyhow::Result { + let bytes = serde_json::to_vec(&( + "mvm.composable-rootfs/v1", + &self.base_manifest_sha256, + &self.runtime_pins, + &self.application_archive_sha256, + self.target, + self.profile, + &self.producer_revision, + &self.producer_version, + ))?; + Ok(Sha256Hex::from_bytes(&bytes)) + } } impl ComposedApplicationRootfs { @@ -229,10 +274,7 @@ impl ComposedApplicationRootfs { ) -> Result { let spec_sha256 = Sha256Hex::from_bytes(&spec.canonical_json()?); if self.tree.authored_spec_sha256.as_ref() != Some(&spec_sha256) - || producer_revision.len() != 40 - || !producer_revision - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + || !valid_producer_revision(producer_revision) { return Err(RuntimeLayerStageError::InconsistentLayers); } @@ -264,6 +306,13 @@ impl ComposedApplicationRootfs { } } +fn valid_producer_revision(revision: &str) -> bool { + revision.len() == 40 + && revision + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + impl ComposedWorkloadRootfs { fn reverify_runtime_policies(&self) -> Result<(), RuntimeLayerStageError> { if self.runtime_policies.len() != self.runtime_pins().len() @@ -421,6 +470,16 @@ impl ComposedRuntimeLayers { { return Err(RuntimeLayerStageError::Collision(path.clone())); } + if *kind == PathKind::Directory + && let Some(mode) = inventory.directory_modes.get(path) + && self.claimed.contains_key(path) + { + use std::os::unix::fs::PermissionsExt; + let existing = std::fs::symlink_metadata(self.root().join(path))?; + if !existing.is_dir() || existing.permissions().mode() & 0o7777 != *mode { + return Err(RuntimeLayerStageError::Collision(path.clone())); + } + } } let options = UnpackOptions::builder() .max_total_bytes(inventory.bytes) @@ -497,7 +556,7 @@ pub enum RuntimeLayerStageError { "host filesystem case-folds distinct guest paths; compose inside a case-sensitive Linux boundary" )] CaseFolding, - #[error("signed base acquisition failed: {0}")] + #[error("base acquisition failed: {0}")] Base(#[from] anyhow::Error), #[error("signed base composition source has invalid contract or ownership")] BaseContract, @@ -505,10 +564,291 @@ pub enum RuntimeLayerStageError { ApplicationDigest, #[error("composable pack inputs differ from the frozen lock")] StaleLock, + #[error("ordered component graph, signed inputs or selected base disagree")] + ComponentGraph, + #[error("component archive differs from its signed descriptor")] + ComponentDigest, + #[error("component base requirements are not satisfied: {0}")] + ComponentBase(#[from] mvm_core::image_set::base_compatibility::BaseCompatibilityError), + #[error("application component input failed: {0}")] + ApplicationComponent( + #[from] mvm_core::registry_pack::application_component::ApplicationComponentInputError, + ), + #[error("runtime/tool component input failed: {0}")] + ComponentInput(#[from] mvm_core::registry_pack::component_layer::ComponentLayerInputError), #[error(transparent)] Materialize(#[from] crate::rootfs::RootfsError), } +/// Select the base for an ordered component graph without upgrading local evidence. +#[derive(Clone, Copy)] +pub enum ComponentGraphBase<'a> { + /// A publisher-verified production image set and its signed compatibility. + Production { + images: &'a crate::published_image_set::PublishedImageSet, + compatibility: &'a ProductionBaseCompatibility, + }, + /// An unsigned local schema-3 set, with exact selected kernel and tar paths. + Development { + compatibility: &'a DevelopmentBaseCompatibility, + evidence: &'a LocalImageSetVerification<'a>, + manifest_bytes: &'a [u8], + kernel: &'a Path, + tar: &'a Path, + }, +} + +/// Apply exact signed v3 components to a matching production or development base. +/// +/// This does not select an entrypoint, resolve policy, seal an image or export +/// a bundle. The caller must supply separately verified base compatibility and +/// held component inputs. Local evidence remains development-only, and its +/// checkout identities must be freshly sampled for this call. +/// +/// # Errors +/// +/// Refuses missing/extra/drifted inputs, unsatisfied requirements, unordered +/// dependencies, revoked signatures, changed archives and path collisions. +pub fn compose_verified_component_graph( + source: ComponentGraphBase<'_>, + graph: &ComposedGraph, + applications: &[VerifiedApplicationComponentInputs], + components: &[VerifiedComponentLayerInputs], +) -> Result { + graph + .validate() + .map_err(|_| RuntimeLayerStageError::ComponentGraph)?; + if graph.graph.len() > MAX_LAYERS || graph.graph.len() != applications.len() + components.len() + { + return Err(RuntimeLayerStageError::ComponentGraph); + } + let descriptor = match &source { + ComponentGraphBase::Production { compatibility, .. } => compatibility.descriptor(), + ComponentGraphBase::Development { compatibility, .. } => compatibility.descriptor(), + }; + let arch = descriptor.arch(); + let mut base = match &source { + ComponentGraphBase::Production { images, .. } => { + if descriptor.image_set_manifest_sha256() + != &Sha256Hex::from_bytes(images.manifest_bytes()) + { + return Err(RuntimeLayerStageError::ComponentGraph); + } + compose_signed_workload_tree(images, arch, descriptor.profile(), &[])? + } + ComponentGraphBase::Development { .. } => compose_local_component_base(&source)?, + }; + let mut pins = Vec::with_capacity(graph.graph.len()); + let mut archives = Vec::with_capacity(graph.graph.len()); + for node in &graph.graph { + let requested = node.pin(); + let pin = graph_pin(requested)?; + match node { + GraphNode::Application { .. } => { + let input = applications + .iter() + .find(|input| { + input.descriptor().manifest().reference == *pin.reference() + && Sha256Hex::from_bytes(input.descriptor().manifest_bytes()) + == *pin.manifest_sha256() + }) + .ok_or(RuntimeLayerStageError::ComponentGraph)?; + let manifest = input.descriptor().manifest(); + if manifest.application_component.guest_arch != arch.to_string() { + return Err(RuntimeLayerStageError::ComponentGraph); + } + check_graph_requirements( + &source, + &manifest.application_component.requirements, + arch, + )?; + let archive = input.open_verified_archive()?; + apply_component_archive( + &mut base.tree, + archive, + &manifest.application_component.archive.archive_sha256, + TarRole::Application, + )?; + input.reverify()?; + archives.push(ComponentArchivePin { + sha256: manifest + .application_component + .archive + .archive_sha256 + .clone(), + size: manifest.application_component.archive.archive_size, + }); + } + GraphNode::Runtime { .. } | GraphNode::Tool { .. } => { + let input = components + .iter() + .find(|input| { + input.descriptor().manifest().reference() == pin.reference() + && input.descriptor().manifest_sha256() == pin.manifest_sha256() + }) + .ok_or(RuntimeLayerStageError::ComponentGraph)?; + let manifest = input.descriptor().manifest(); + let expected_kind = match node { + GraphNode::Runtime { .. } => ComponentLayerKind::Runtime, + GraphNode::Tool { .. } => ComponentLayerKind::Tool, + GraphNode::Application { .. } => unreachable!("matched component node"), + }; + if manifest.kind() != expected_kind + || manifest.component().guest_arch != arch.to_string() + || !dependencies_before(&manifest.component().dependencies, &pins) + { + return Err(RuntimeLayerStageError::ComponentGraph); + } + check_graph_requirements(&source, &manifest.component().requirements, arch)?; + let archive = input.open_verified_archive()?; + apply_component_archive( + &mut base.tree, + archive, + &manifest.component().archive.archive_sha256, + TarRole::Runtime, + )?; + input.reverify()?; + archives.push(ComponentArchivePin { + sha256: manifest.component().archive.archive_sha256.clone(), + size: manifest.component().archive.archive_size, + }); + } + } + pins.push(pin); + } + // A later signer must not infer trust merely from this staged tree. + for input in applications { + input.reverify()?; + } + for input in components { + input.reverify()?; + } + let base_descriptor_sha256 = Sha256Hex::from_bytes(match &source { + ComponentGraphBase::Production { compatibility, .. } => compatibility.descriptor_bytes(), + ComponentGraphBase::Development { compatibility, .. } => compatibility.descriptor_bytes(), + }); + let tier = match source { + ComponentGraphBase::Production { images, .. } => { + images.retained_publisher_proof()?; + ImageTrustTier::VerifiedRelease + } + ComponentGraphBase::Development { + compatibility, + evidence, + manifest_bytes, + kernel, + tar, + } => { + reverify_local_component_base(compatibility, evidence, manifest_bytes, kernel, tar)?; + ImageTrustTier::LocalDev + } + }; + Ok(ComposedComponentRootfs { + tree: base, + pins, + archives, + base_descriptor_sha256, + arch, + tier, + }) +} + +fn reverify_local_component_base( + compatibility: &DevelopmentBaseCompatibility, + evidence: &LocalImageSetVerification<'_>, + manifest_bytes: &[u8], + kernel: &Path, + tar: &Path, +) -> Result<(), RuntimeLayerStageError> { + let current = compatibility.reverify(evidence)?; + let (selected_kernel, selected_tar) = current.artifacts(); + if current.descriptor().image_set_manifest_sha256() != &Sha256Hex::from_bytes(manifest_bytes) + || selected_kernel.path != kernel + || selected_tar.path != tar + { + return Err(RuntimeLayerStageError::ComponentGraph); + } + Ok(()) +} + +fn compose_local_component_base( + source: &ComponentGraphBase<'_>, +) -> Result { + let ComponentGraphBase::Development { + compatibility, + evidence, + manifest_bytes, + kernel, + tar, + } = source + else { + return Err(RuntimeLayerStageError::ComponentGraph); + }; + reverify_local_component_base(compatibility, evidence, manifest_bytes, kernel, tar)?; + compose_local_workload_tree( + manifest_bytes, + kernel, + tar, + compatibility.descriptor().arch(), + compatibility.descriptor().profile(), + &[], + ) +} + +fn check_graph_requirements( + source: &ComponentGraphBase<'_>, + requirements: &mvm_core::registry_pack::application_component::ComponentRequirements, + arch: GuestArch, +) -> Result<(), RuntimeLayerStageError> { + match source { + ComponentGraphBase::Production { compatibility, .. } => { + compatibility.check_component_requirements(requirements, arch)?; + } + ComponentGraphBase::Development { compatibility, .. } => { + compatibility.check_component_requirements(requirements, arch)?; + } + } + Ok(()) +} + +fn graph_pin(requested: &SignedPackPin) -> Result { + let reference = format!("{}@{}", requested.name, requested.version) + .parse() + .map_err(|_| RuntimeLayerStageError::ComponentGraph)?; + let digest = Sha256Hex::new(&requested.manifest_sha256) + .map_err(|_| RuntimeLayerStageError::ComponentGraph)?; + PackPin::new(reference, digest).map_err(|_| RuntimeLayerStageError::ComponentGraph) +} + +fn dependencies_before(dependencies: &[PackPin], preceding: &[PackPin]) -> bool { + dependencies.iter().all(|pin| preceding.contains(pin)) +} + +fn apply_component_archive( + tree: &mut ComposedRuntimeLayers, + mut archive: std::fs::File, + expected: &Sha256Hex, + role: TarRole, +) -> Result<(), RuntimeLayerStageError> { + let size = archive.metadata()?.len(); + if mvm_core::crypto::image_verify::sha256_reader(&mut archive)? != expected.as_str() { + return Err(RuntimeLayerStageError::ComponentDigest); + } + archive.seek(SeekFrom::Start(0))?; + let mut magic = [0; 2]; + archive.read_exact(&mut magic)?; + archive.seek(SeekFrom::Start(0))?; + if magic == [0x1f, 0x8b] { + let mut tar = tempfile::tempfile()?; + decompress_single_gzip(io::BufReader::new(archive), size, &mut tar)?; + tar.seek(SeekFrom::Start(0))?; + tree.apply_tar(&mut tar, role)?; + } else { + tree.apply_tar(&mut archive, role)?; + } + Ok(()) +} + /// Verify revocation and snapshot custody around strict, bounded extraction. /// /// # Errors @@ -565,6 +905,35 @@ pub fn compose_authored_candidate( images: &crate::published_image_set::PublishedImageSet, profile: WorkloadImageProfile, inputs: &[&VerifiedRuntimeLayerInputs], +) -> Result { + compose_authored_candidate_with(spec, inputs, |arch, archives| { + compose_signed_workload_tree(images, arch, profile, archives) + }) +} + +/// Compose an explicitly selected local-development base. The local manifest +/// must declare both input files; its ext4 artifact is not treated as the +/// composition tree or as evidence that it matches the tar. +pub fn compose_local_authored_candidate( + spec: &ComposablePackSpec, + manifest_bytes: &[u8], + kernel: &Path, + tar: &Path, + profile: WorkloadImageProfile, + inputs: &[&VerifiedRuntimeLayerInputs], +) -> Result { + compose_authored_candidate_with(spec, inputs, |arch, archives| { + compose_local_workload_tree(manifest_bytes, kernel, tar, arch, profile, archives) + }) +} + +fn compose_authored_candidate_with( + spec: &ComposablePackSpec, + inputs: &[&VerifiedRuntimeLayerInputs], + select_base: impl FnOnce( + GuestArch, + &[&VerifiedRuntimeLayerArchive], + ) -> Result, ) -> Result { spec.validate()?; for input in inputs { @@ -589,7 +958,7 @@ pub fn compose_authored_candidate( PackTarget::X86_64Linux => GuestArch::X86_64, PackTarget::Aarch64Linux => GuestArch::Aarch64, }; - let mut composed = compose_signed_workload_tree(images, arch, profile, &archives)?; + let mut composed = select_base(arch, &archives)?; for input in inputs { // add_runtime_archives already rehashes each archive before and after // extraction; the small signed policy bytes still need a final check. @@ -603,6 +972,98 @@ pub fn compose_authored_candidate( Ok(composed) } +/// Require a local schema-3 dev pair with its own declared tar, then use +/// exactly those bytes as the base and selected kernel for a dev-only build. +pub fn compose_local_workload_tree( + manifest_bytes: &[u8], + kernel: &Path, + tar: &Path, + arch: GuestArch, + profile: WorkloadImageProfile, + archives: &[&VerifiedRuntimeLayerArchive], +) -> Result { + if manifest_bytes.len() > mvm_core::plan::bundle::MAX_IMAGE_SET_MANIFEST_BYTES as usize { + return Err(RuntimeLayerStageError::Unsupported( + "local base manifest exceeds its bound", + )); + } + let manifest: ImageSetManifest = serde_json::from_slice(manifest_bytes) + .map_err(|_| RuntimeLayerStageError::Unsupported("invalid local base manifest"))?; + mvm_core::image_set::check_workload_protocol_compatibility( + &manifest.compatibility, + &crate::stage0_kernel::current_image_set_protocol_support(), + ) + .map_err(|error| RuntimeLayerStageError::Base(error.into()))?; + let (kernel_artifact, tar_artifact) = + mvm_core::plan::bundle::select_local_development_source(&manifest, arch, profile) + .map_err(|error| RuntimeLayerStageError::Base(error.into()))?; + let ArtifactFormat::Kernel(declared_kernel_format) = kernel_artifact.format else { + return Err(RuntimeLayerStageError::Unsupported( + "local dev kernel format is invalid", + )); + }; + if kernel_artifact.size > 256 * 1024 * 1024 { + return Err(RuntimeLayerStageError::Unsupported( + "local base kernel exceeds the bundle capture bound", + )); + } + if tar_artifact.size > 2 * 1024 * 1024 * 1024 { + return Err(RuntimeLayerStageError::Unsupported( + "local base tar exceeds its bound", + )); + } + for (path, artifact) in [(kernel, kernel_artifact), (tar, tar_artifact)] { + let metadata = std::fs::symlink_metadata(path)?; + if !metadata.file_type().is_file() + || metadata.len() != artifact.size + || mvm_core::crypto::image_verify::sha256_file(path)? != artifact.sha256.as_str() + { + return Err(RuntimeLayerStageError::Unsupported( + "local base kernel or composition tar differs from its declared bytes", + )); + } + } + let mut head = vec![0; mvm_core::kernel_format::KernelFormat::SNIFF_LEN]; + std::io::Read::read_exact(&mut std::fs::File::open(kernel)?, &mut head)?; + let actual_kernel_format = mvm_core::kernel_format::KernelFormat::sniff_magic(&head); + let expected_machine = match arch { + GuestArch::X86_64 => 62u16, + GuestArch::Aarch64 => 183u16, + }; + if actual_kernel_format != Some(declared_kernel_format) + || (declared_kernel_format == mvm_core::kernel_format::KernelFormat::Elf + && head.get(18..20) != Some(expected_machine.to_le_bytes().as_slice())) + { + return Err(RuntimeLayerStageError::Unsupported( + "local kernel bytes do not match the declared format or architecture", + )); + } + let pins = runtime_pins(archives, &arch.to_string())?; + let mut decompressed = tempfile::tempfile()?; + decompress_single_gzip( + io::BufReader::new(std::fs::File::open(tar)?), + tar_artifact.size, + &mut decompressed, + )?; + if mvm_core::crypto::image_verify::sha256_file(tar)? != tar_artifact.sha256.as_str() { + return Err(RuntimeLayerStageError::Unsupported( + "local base tar changed while decompressing", + )); + } + decompressed.seek(SeekFrom::Start(0))?; + let mut composed = ComposedRuntimeLayers::new(pins)?; + composed.add_base_tar(&mut decompressed)?; + check_base_contract(&composed)?; + add_runtime_archives(&mut composed, archives)?; + Ok(ComposedWorkloadRootfs { + tree: composed, + base_manifest_sha256: Sha256Hex::from_bytes(manifest_bytes), + profile, + authored_spec_sha256: None, + runtime_policies: Vec::new(), + }) +} + /// Combine a signed generic base and ordered runtime layers without Nix. /// /// A verified image-set root selects the production tar; its compressed bytes @@ -1075,6 +1536,109 @@ mod tests { } } + #[test] + fn component_archive_refuses_tamper_before_extraction() { + use std::io::Write as _; + + let bytes = archive(&[("bin/tool", b"trusted", tar::EntryType::Regular, 0o755)]); + let mut snapshot = tempfile::tempfile().unwrap(); + snapshot.write_all(&bytes).unwrap(); + snapshot.rewind().unwrap(); + let mut accepted = ComposedRuntimeLayers::new(Vec::new()).unwrap(); + apply_component_archive( + &mut accepted, + snapshot.try_clone().unwrap(), + &Sha256Hex::from_bytes(&bytes), + TarRole::Runtime, + ) + .unwrap(); + assert_eq!( + std::fs::read(accepted.root().join("bin/tool")).unwrap(), + b"trusted" + ); + snapshot.rewind().unwrap(); + let mut tree = ComposedRuntimeLayers::new(Vec::new()).unwrap(); + let wrong = Sha256Hex::from_bytes(b"different signed archive"); + assert!(matches!( + apply_component_archive(&mut tree, snapshot, &wrong, TarRole::Runtime), + Err(RuntimeLayerStageError::ComponentDigest) + )); + assert!(!tree.root().join("bin/tool").exists()); + } + + #[test] + fn component_accepts_digest_pinned_gzip_without_an_extension_rule() { + use std::io::Write as _; + + let raw = archive(&[("bin/tool", b"compressed", tar::EntryType::Regular, 0o755)]); + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + encoder.write_all(&raw).unwrap(); + let gzip = encoder.finish().unwrap(); + let mut snapshot = tempfile::tempfile().unwrap(); + snapshot.write_all(&gzip).unwrap(); + snapshot.rewind().unwrap(); + let mut tree = ComposedRuntimeLayers::new(Vec::new()).unwrap(); + apply_component_archive( + &mut tree, + snapshot, + &Sha256Hex::from_bytes(&gzip), + TarRole::Runtime, + ) + .unwrap(); + assert_eq!( + std::fs::read(tree.root().join("bin/tool")).unwrap(), + b"compressed" + ); + } + + #[test] + fn component_dependencies_require_exact_preceding_manifest_pins() { + let reference = "runtime/python@1.0.0".parse().unwrap(); + let pinned = PackPin::new(reference, Sha256Hex::from_bytes(b"trusted manifest")).unwrap(); + let drift = PackPin::new( + pinned.reference().clone(), + Sha256Hex::from_bytes(b"different manifest"), + ) + .unwrap(); + assert!(!dependencies_before(std::slice::from_ref(&pinned), &[])); + assert!(!dependencies_before( + std::slice::from_ref(&pinned), + &[drift] + )); + assert!(dependencies_before( + std::slice::from_ref(&pinned), + std::slice::from_ref(&pinned) + )); + } + + #[test] + fn ordered_components_refuse_leaf_and_directory_mode_collisions() { + let mut tree = ComposedRuntimeLayers::new(Vec::new()).unwrap(); + let first = archive(&[ + ("usr", b"", tar::EntryType::Directory, 0o755), + ("usr/bin/tool", b"first", tar::EntryType::Regular, 0o755), + ]); + tree.add_tar(&mut Cursor::new(first)).unwrap(); + let overlapping = archive(&[("usr/bin/tool", b"second", tar::EntryType::Regular, 0o755)]); + assert!(matches!( + tree.add_tar(&mut Cursor::new(overlapping)), + Err(RuntimeLayerStageError::Collision(_)) + )); + let changed_mode = archive(&[ + ("usr", b"", tar::EntryType::Directory, 0o777), + ("usr/bin/other", b"other", tar::EntryType::Regular, 0o755), + ]); + assert!(matches!( + tree.add_tar(&mut Cursor::new(changed_mode)), + Err(RuntimeLayerStageError::Collision(_)) + )); + assert_eq!( + std::fs::read(tree.root().join("usr/bin/tool")).unwrap(), + b"first" + ); + assert!(!tree.root().join("usr/bin/other").exists()); + } + #[test] fn runtime_tar_refuses_changes_to_earlier_layers_and_boot() { for path in [ @@ -1255,6 +1819,413 @@ mod tests { } } + #[test] + fn local_composition_uses_only_the_declared_kernel_and_tar() { + use crate::published_image_set::fixture::ImageSetFixture; + use mvm_core::image_set::{ + BaseImageContract, BuilderBootAbi, GuestDeviceRequirement, ImageSetRole, + MemberArtifact, MemberTarget, + }; + + let directory = tempfile::tempdir().unwrap(); + let arch = GuestArch::host(); + let profile = WorkloadImageProfile::DefaultTenant; + let kernel = directory.path().join("vmlinux"); + let tar = directory.path().join("rootfs.tar.gz"); + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + std::io::Write::write_all(&mut encoder, &base_tar(b"1\n", 901)).unwrap(); + let tar_bytes = encoder.finish().unwrap(); + let mut kernel_bytes = vec![0u8; mvm_core::kernel_format::KernelFormat::SNIFF_LEN]; + match arch { + GuestArch::X86_64 => { + kernel_bytes[..4].copy_from_slice(b"\x7fELF"); + kernel_bytes[18..20].copy_from_slice(&62u16.to_le_bytes()); + } + GuestArch::Aarch64 => { + kernel_bytes[56..60].copy_from_slice(&0x644D_5241u32.to_le_bytes()); + } + } + std::fs::write(&kernel, &kernel_bytes).unwrap(); + std::fs::write(&tar, &tar_bytes).unwrap(); + let mut manifest = ImageSetFixture::complete() + .with_base_image_contract(Some(BaseImageContract::LINUX_LAYER)) + .publish( + ImageSetRole::WorkloadKernel(profile), + MemberTarget::Arch(arch), + "local-vmlinux", + kernel_bytes.clone(), + ) + .publish( + ImageSetRole::WorkloadRootfs(profile), + MemberTarget::Arch(arch), + "local-rootfs.ext4", + b"unrelated ext4".to_vec(), + ) + .into_manifest(); + manifest.members.retain(|member| { + member.target == MemberTarget::Arch(arch) + && matches!( + member.role, + ImageSetRole::WorkloadKernel(_) | ImageSetRole::WorkloadRootfs(_) + ) + }); + manifest.schema_version = mvm_core::image_set::IMAGE_SET_SCHEMA_VERSION; + let release_producer = manifest.producer.clone(); + manifest.producer = serde_json::from_value(serde_json::json!({ + "local_checkouts": { + "images": { + "commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "worktree": {"state": "clean"} + } + } + })) + .unwrap(); + manifest.mvm_source_commit = None; + manifest.revocation_channel = None; + manifest.compatibility.guest_agent_protocol = None; + manifest.compatibility.builder_boot_abi = Some(BuilderBootAbi::NO_MVM_BINARY); + for member in &mut manifest.members { + member.pack_hash = None; + member.sbom = None; + match member.role { + ImageSetRole::WorkloadKernel(_) => { + member.required_capabilities = vec![GuestDeviceRequirement::VirtioVsock]; + } + ImageSetRole::WorkloadRootfs(_) => { + member.required_capabilities = vec![GuestDeviceRequirement::VirtioBlk]; + member.artifacts.push(MemberArtifact { + name: mvm_core::image_set::ArtifactName::new("local-rootfs.tar.gz") + .unwrap(), + format: ArtifactFormat::TarGz, + sha256: Sha256Hex::from_bytes(&tar_bytes), + size: tar_bytes.len() as u64, + }); + } + _ => unreachable!(), + } + } + let bytes = serde_json::to_vec(&manifest).unwrap(); + let composed = + compose_local_workload_tree(&bytes, &kernel, &tar, arch, profile, &[]).unwrap(); + assert_eq!( + std::fs::read(composed.root().join("usr/bin/sh")).unwrap(), + b"busybox" + ); + assert_eq!( + composed.base_manifest_sha256(), + &Sha256Hex::from_bytes(&bytes) + ); + drop(composed); + + std::fs::write(&kernel, b"forged kernel").unwrap(); + assert!(compose_local_workload_tree(&bytes, &kernel, &tar, arch, profile, &[]).is_err()); + std::fs::write(&kernel, &kernel_bytes).unwrap(); + std::fs::write(&tar, b"unrelated ext4").unwrap(); + assert!(compose_local_workload_tree(&bytes, &kernel, &tar, arch, profile, &[]).is_err()); + std::fs::write(&tar, &tar_bytes).unwrap(); + manifest.compatibility.builder_boot_abi = Some(BuilderBootAbi::LEGACY); + assert!( + compose_local_workload_tree( + &serde_json::to_vec(&manifest).unwrap(), + &kernel, + &tar, + arch, + profile, + &[] + ) + .is_err() + ); + manifest.compatibility.builder_boot_abi = Some(BuilderBootAbi::NO_MVM_BINARY); + manifest + .members + .iter_mut() + .find(|member| member.role == ImageSetRole::WorkloadRootfs(profile)) + .unwrap() + .artifacts + .retain(|artifact| artifact.format != ArtifactFormat::TarGz); + assert!( + compose_local_workload_tree( + &serde_json::to_vec(&manifest).unwrap(), + &kernel, + &tar, + arch, + profile, + &[] + ) + .is_err(), + "an ext4 declaration is not a composition tree" + ); + manifest.producer = release_producer; + assert!( + compose_local_workload_tree( + &serde_json::to_vec(&manifest).unwrap(), + &kernel, + &tar, + arch, + profile, + &[] + ) + .is_err(), + "a release claim cannot enter the local lane" + ); + } + + #[test] + fn component_graph_local_base_is_development_and_rejects_mismatches_and_tampering() { + use crate::published_image_set::fixture::ImageSetFixture; + use mvm_core::image_set::base_compatibility::verify_development_base_compatibility; + use mvm_core::image_set::{ + BaseImageContract, BuilderBootAbi, GuestDeviceRequirement, ImageSetProducer, + ImageSetRole, LocalCheckouts, MemberArtifact, MemberTarget, + }; + use mvm_core::registry_pack::application_component::ComponentRequirements; + + let directory = tempfile::tempdir().unwrap(); + let arch = GuestArch::host(); + let profile = WorkloadImageProfile::DefaultTenant; + let mut kernel_bytes = vec![0; mvm_core::kernel_format::KernelFormat::SNIFF_LEN]; + match arch { + GuestArch::X86_64 => { + kernel_bytes[..4].copy_from_slice(b"\x7fELF"); + kernel_bytes[18..20].copy_from_slice(&62u16.to_le_bytes()); + } + GuestArch::Aarch64 => { + kernel_bytes[56..60].copy_from_slice(&0x644D_5241u32.to_le_bytes()); + } + } + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + std::io::Write::write_all(&mut encoder, &base_tar(b"1\n", 901)).unwrap(); + let tar_bytes = encoder.finish().unwrap(); + let kernel = directory.path().join("local-vmlinux"); + let tar = directory.path().join("local-rootfs.tar.gz"); + std::fs::write(&kernel, &kernel_bytes).unwrap(); + std::fs::write(&tar, &tar_bytes).unwrap(); + std::fs::write(directory.path().join("local-rootfs.ext4"), b"ext4").unwrap(); + let current: LocalCheckouts = serde_json::from_value(serde_json::json!({ + "images": {"commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "worktree": {"state": "clean"}} + })) + .unwrap(); + let mut manifest = ImageSetFixture::complete() + .with_base_image_contract(Some(BaseImageContract::LINUX_LAYER)) + .publish( + ImageSetRole::WorkloadKernel(profile), + MemberTarget::Arch(arch), + "local-vmlinux", + kernel_bytes.clone(), + ) + .publish( + ImageSetRole::WorkloadRootfs(profile), + MemberTarget::Arch(arch), + "local-rootfs.ext4", + b"ext4".to_vec(), + ) + .into_manifest(); + manifest.members.retain(|member| { + member.target == MemberTarget::Arch(arch) + && (member.role == ImageSetRole::WorkloadKernel(profile) + || member.role == ImageSetRole::WorkloadRootfs(profile)) + }); + manifest.schema_version = mvm_core::image_set::IMAGE_SET_SCHEMA_VERSION; + manifest.producer = ImageSetProducer::LocalCheckouts(current.clone()); + manifest.mvm_source_commit = None; + manifest.revocation_channel = None; + manifest.compatibility.guest_agent_protocol = None; + manifest.compatibility.builder_boot_abi = Some(BuilderBootAbi::NO_MVM_BINARY); + for member in &mut manifest.members { + member.pack_hash = None; + member.sbom = None; + match member.role { + ImageSetRole::WorkloadKernel(_) => { + member.required_capabilities = vec![GuestDeviceRequirement::VirtioVsock]; + } + ImageSetRole::WorkloadRootfs(_) => { + member.required_capabilities = vec![GuestDeviceRequirement::VirtioBlk]; + member.artifacts.push(MemberArtifact { + name: mvm_core::image_set::ArtifactName::new("local-rootfs.tar.gz") + .unwrap(), + format: ArtifactFormat::TarGz, + sha256: Sha256Hex::from_bytes(&tar_bytes), + size: tar_bytes.len() as u64, + }); + } + _ => unreachable!(), + } + } + let manifest_bytes = serde_json::to_vec(&manifest).unwrap(); + let descriptor_bytes = serde_json::to_vec(&serde_json::json!({ + "schema": "base-compatibility/v1", + "image_set_manifest_sha256": Sha256Hex::from_bytes(&manifest_bytes), + "arch": if arch == GuestArch::X86_64 { "x86_64" } else { "aarch64" }, + "profile": "default_tenant", + "kernel": {"name": "local-vmlinux", "sha256": Sha256Hex::from_bytes(&kernel_bytes), "size": kernel_bytes.len()}, + "composition_tar": {"name": "local-rootfs.tar.gz", "sha256": Sha256Hex::from_bytes(&tar_bytes), "size": tar_bytes.len()}, + "base_image_contract": 1, "boot_protocol": "linux_direct", "filesystem_abi": 1, + "kernel_version": "6.12.3", + "libc_loaders": [{"libc": "musl", "path": if arch == GuestArch::X86_64 { "/lib/ld-musl-x86_64.so.1" } else { "/lib/ld-musl-aarch64.so.1" }}], + "kernel_capabilities": ["virtio_vsock"] + })) + .unwrap(); + let evidence = + LocalImageSetVerification::new(&manifest_bytes, directory.path(), ¤t, arch); + let compatibility = + verify_development_base_compatibility(&descriptor_bytes, &evidence, arch, profile) + .unwrap(); + let source = ComponentGraphBase::Development { + compatibility: &compatibility, + evidence: &evidence, + manifest_bytes: &manifest_bytes, + kernel: &kernel, + tar: &tar, + }; + let base = compose_local_component_base(&source).unwrap(); + assert_eq!( + std::fs::read(base.root().join("usr/bin/sh")).unwrap(), + b"busybox" + ); + assert_eq!( + base.base_manifest_sha256(), + &Sha256Hex::from_bytes(&manifest_bytes) + ); + let candidate = ComposedComponentRootfs { + tree: base, + pins: Vec::new(), + archives: Vec::new(), + base_descriptor_sha256: Sha256Hex::from_bytes(&descriptor_bytes), + arch, + tier: ImageTrustTier::LocalDev, + }; + let output = tempfile::tempdir().unwrap(); + let image = output.path().join("rootfs.ext4"); + assert!( + candidate + .materialize_candidate(&image, "not-a-revision", source, &[], &[]) + .is_err() + ); + assert!(!image.exists()); + let materialized = candidate + .materialize_candidate(&image, &"a".repeat(40), source, &[], &[]) + .unwrap(); + assert_eq!(materialized.lock.tier(), ImageTrustTier::LocalDev); + materialized + .lock + .verify_materialized_bytes(&materialized.image) + .unwrap(); + let encoded = serde_json::to_vec(&materialized.lock).unwrap(); + let restored: ResolvedComponentFilesystemLock = serde_json::from_slice(&encoded).unwrap(); + restored.verify_current(&materialized.lock).unwrap(); + let filesystem = restored.filesystem_identity().unwrap(); + let mut changed = restored.clone(); + changed.verity_sha256 = Sha256Hex::from_bytes(b"changed output"); + assert_eq!(changed.filesystem_identity().unwrap(), filesystem); + assert!(restored.verify_current(&changed).is_err()); + let reference = "agent/example@1.0.0".parse().unwrap(); + changed = restored.clone(); + changed.components.push(( + PackPin::new(reference, Sha256Hex::from_bytes(b"first signed manifest")).unwrap(), + ComponentArchivePin { + sha256: Sha256Hex::from_bytes(b"application bytes"), + size: b"application bytes".len() as u64, + }, + )); + let archive_identity = changed.filesystem_identity().unwrap(); + changed.components[0].0 = PackPin::new( + "agent/example@1.0.0".parse().unwrap(), + Sha256Hex::from_bytes(b"changed signed policy or command"), + ) + .unwrap(); + assert_eq!(changed.filesystem_identity().unwrap(), archive_identity); + changed.components[0].1.sha256 = Sha256Hex::from_bytes(b"different application bytes"); + assert_ne!(changed.filesystem_identity().unwrap(), archive_identity); + changed = restored.clone(); + changed.producer_revision = "b".repeat(40); + assert_ne!(changed.filesystem_identity().unwrap(), filesystem); + changed = restored.clone(); + changed.base_tier = ImageTrustTier::VerifiedRelease; + assert_ne!(changed.filesystem_identity().unwrap(), filesystem); + std::fs::write(output.path().join("rootfs.roothash"), b"tampered").unwrap(); + assert!( + restored + .verify_materialized_bytes(&materialized.image) + .is_err() + ); + let requirements = ComponentRequirements { + filesystem_abi: 1, + libc: "musl".into(), + loader: Some( + if arch == GuestArch::X86_64 { + "/lib/ld-musl-x86_64.so.1" + } else { + "/lib/ld-musl-aarch64.so.1" + } + .into(), + ), + minimum_kernel: "6.12.3".into(), + required_capabilities: vec!["virtio_vsock".into()], + }; + check_graph_requirements(&source, &requirements, arch).unwrap(); + let mut incompatible = requirements.clone(); + incompatible.minimum_kernel = "6.12.4".into(); + assert!(matches!( + check_graph_requirements(&source, &incompatible, arch), + Err(RuntimeLayerStageError::ComponentBase(_)) + )); + + let wrong_manifest = b"another image set"; + assert!(matches!( + reverify_local_component_base(&compatibility, &evidence, wrong_manifest, &kernel, &tar), + Err(RuntimeLayerStageError::ComponentGraph) + )); + let alternate_kernel = directory.path().join("alternate-vmlinux"); + std::fs::write(&alternate_kernel, &kernel_bytes).unwrap(); + assert!(matches!( + reverify_local_component_base( + &compatibility, + &evidence, + &manifest_bytes, + &alternate_kernel, + &tar + ), + Err(RuntimeLayerStageError::ComponentGraph) + )); + let alternate_tar = directory.path().join("alternate-rootfs.tar.gz"); + std::fs::write(&alternate_tar, &tar_bytes).unwrap(); + assert!(matches!( + reverify_local_component_base( + &compatibility, + &evidence, + &manifest_bytes, + &kernel, + &alternate_tar + ), + Err(RuntimeLayerStageError::ComponentGraph) + )); + let changed_checkout: LocalCheckouts = serde_json::from_value(serde_json::json!({ + "images": {"commit": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "worktree": {"state": "clean"}} + })) + .unwrap(); + let stale_evidence = LocalImageSetVerification::new( + &manifest_bytes, + directory.path(), + &changed_checkout, + arch, + ); + assert!(matches!( + reverify_local_component_base( + &compatibility, + &stale_evidence, + &manifest_bytes, + &kernel, + &tar + ), + Err(RuntimeLayerStageError::ComponentBase(_)) + )); + std::fs::write(&tar, b"tampered").unwrap(); + assert!(matches!( + compose_local_component_base(&source), + Err(RuntimeLayerStageError::ComponentBase(_)) + )); + } + #[test] fn one_application_is_sealed_into_the_composed_rootfs() { use std::io::Write as _; @@ -1591,6 +2562,11 @@ mod tests { let locked_bytes = serde_json::to_vec(&lock).unwrap(); let restored: ResolvedComposableLock = serde_json::from_slice(&locked_bytes).unwrap(); restored.verify_current(&lock).unwrap(); + let filesystem = restored.filesystem_identity().unwrap(); + let mut command_only = lock.clone(); + command_only.spec_sha256 = Sha256Hex::from_bytes(b"another admitted command"); + assert_ne!(command_only, lock); + assert_eq!(command_only.filesystem_identity().unwrap(), filesystem); let mut changed = lock.clone(); changed.source_tree_sha256 = Sha256Hex::from_bytes(b"edited source"); assert!(matches!( @@ -1599,12 +2575,14 @@ mod tests { )); changed = lock.clone(); changed.base_manifest_sha256 = Sha256Hex::from_bytes(b"replaced base"); + assert_ne!(changed.filesystem_identity().unwrap(), filesystem); assert!(matches!( restored.verify_current(&changed), Err(RuntimeLayerStageError::StaleLock) )); changed = lock.clone(); changed.application_archive_sha256 = Sha256Hex::from_bytes(b"changed application"); + assert_ne!(changed.filesystem_identity().unwrap(), filesystem); assert!(matches!( restored.verify_current(&changed), Err(RuntimeLayerStageError::StaleLock) diff --git a/crates/mvm-build/src/runtime_layer/component_lock.rs b/crates/mvm-build/src/runtime_layer/component_lock.rs new file mode 100644 index 0000000000..479907ef90 --- /dev/null +++ b/crates/mvm-build/src/runtime_layer/component_lock.rs @@ -0,0 +1,326 @@ +//! Filesystem-only identity and measured materialization of a verified component graph. + +use std::path::Path; + +use mvm_core::arch::GuestArch; +use mvm_core::image_set::{ImageTrustTier, WorkloadImageProfile}; +use mvm_core::packs::Sha256Hex; +use mvm_core::registry_pack::PackPin; +use mvm_core::registry_pack::application_component::VerifiedApplicationComponentInputs; +use mvm_core::registry_pack::component_layer::VerifiedComponentLayerInputs; +use serde::{Deserialize, Serialize}; + +use super::{ + ComponentGraphBase, ComposedComponentRootfs, RuntimeLayerStageError, + reverify_local_component_base, valid_producer_revision, +}; + +/// Exact archive bytes declared by one signed graph component. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ComponentArchivePin { + pub sha256: Sha256Hex, + pub size: u64, +} + +/// Filesystem-only v3 graph evidence, not a policy, command or signing grant. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ComponentFilesystemLockSchema { + #[serde(rename = "mvm.composed-filesystem-lock/v1")] + V1, +} + +/// Exact inputs and measured output of a private composed filesystem. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ResolvedComponentFilesystemLock { + schema: ComponentFilesystemLockSchema, + base_manifest_sha256: Sha256Hex, + base_descriptor_sha256: Sha256Hex, + pub(super) base_tier: ImageTrustTier, + arch: GuestArch, + profile: WorkloadImageProfile, + pub(super) components: Vec<(PackPin, ComponentArchivePin)>, + pub(super) producer_revision: String, + producer_version: String, + ext4_sha256: Sha256Hex, + pub(super) verity_sha256: Sha256Hex, + verity_root_hash: Sha256Hex, +} + +impl ResolvedComponentFilesystemLock { + /// Hash the exact filesystem inputs, excluding output bytes and launch decisions. + /// + /// # Errors + /// + /// Returns a serialization error if a lock field cannot be encoded. + pub fn filesystem_identity(&self) -> anyhow::Result { + let archives: Vec<&ComponentArchivePin> = + self.components.iter().map(|(_, archive)| archive).collect(); + let bytes = serde_json::to_vec(&( + "mvm.composed-filesystem/v1", + &self.base_manifest_sha256, + &self.base_descriptor_sha256, + self.base_tier, + self.arch, + self.profile, + archives, + &self.producer_revision, + &self.producer_version, + ))?; + Ok(Sha256Hex::from_bytes(&bytes)) + } + + /// The base trust tier cannot be promoted by a composed filesystem lock. + pub fn tier(&self) -> ImageTrustTier { + self.base_tier + } + + /// Digest of the exact written ext4 image. + pub fn ext4_sha256(&self) -> &Sha256Hex { + &self.ext4_sha256 + } + + /// Digest of the exact written dm-verity hash-tree sidecar. + pub fn verity_sha256(&self) -> &Sha256Hex { + &self.verity_sha256 + } + + /// Measured dm-verity root hash, not a health or boot witness. + pub fn verity_root_hash(&self) -> &Sha256Hex { + &self.verity_root_hash + } + + /// Compare freshly resolved input and output pins before consuming this lock again. + /// + /// # Errors + /// + /// Refuses a different graph, assembler, base or materialized result. + pub fn verify_current(&self, current: &Self) -> Result<(), RuntimeLayerStageError> { + if self != current { + return Err(RuntimeLayerStageError::StaleLock); + } + Ok(()) + } + + /// Rehash the materialized image and sidecars against this filesystem lock. + /// + /// # Errors + /// + /// Refuses changed ext4, verity or root-hash bytes. + pub fn verify_materialized_bytes( + &self, + image: &crate::rootfs::MaterializedExt4, + ) -> Result<(), RuntimeLayerStageError> { + let (ext4, verity, root) = measure_materialized_component(image)?; + if ext4 != self.ext4_sha256 || verity != self.verity_sha256 || root != self.verity_root_hash + { + return Err(RuntimeLayerStageError::StaleLock); + } + Ok(()) + } +} + +/// Private image and its filesystem-only lock; neither grants launch or export. +#[derive(Debug)] +pub struct MaterializedComponentRootfs { + pub image: crate::rootfs::MaterializedExt4, + pub lock: ResolvedComponentFilesystemLock, +} + +impl ComposedComponentRootfs { + /// Trust tier of the selected base; a local base remains development-only. + pub fn tier(&self) -> ImageTrustTier { + self.tier + } + + /// The staged tree, which remains private until separately sealed and admitted. + pub fn root(&self) -> &Path { + self.tree.root() + } + + /// Original file owners to preserve during later image materialization. + pub fn ownership(&self) -> &mvm_fs::ownership::OwnerTable { + self.tree.ownership() + } + + /// Exact signed manifest pins in filesystem application order. + pub fn pins(&self) -> &[PackPin] { + &self.pins + } + + /// Write ext4 and verity using the existing composer, binding measured bytes. + /// + /// The caller retains the same verified base and component inputs. Neither + /// this filesystem-only lock nor the image selects policy or a launch command. + /// `producer_revision` must be the caller's compiled `MVM_SOURCE_COMMIT`, + /// never an authored value. A production export must separately reverify + /// the signed base descriptor with its held image-set verification request; + /// `PublishedImageSet` alone cannot reconstruct that request here. + /// + /// # Errors + /// + /// Refuses changed producer, base, component or output bytes. + pub fn materialize_candidate( + &self, + output: &Path, + producer_revision: &str, + base: ComponentGraphBase<'_>, + applications: &[VerifiedApplicationComponentInputs], + components: &[VerifiedComponentLayerInputs], + ) -> Result { + if !valid_producer_revision(producer_revision) { + return Err(RuntimeLayerStageError::ComponentGraph); + } + self.reverify_base(&base)?; + self.reverify_components(applications, components)?; + let image = self.tree.materialize_candidate(output)?; + self.reverify_base(&base)?; + self.reverify_components(applications, components)?; + let (ext4_sha256, verity_sha256, verity_root_hash) = + measure_materialized_component(&image)?; + let lock = ResolvedComponentFilesystemLock { + schema: ComponentFilesystemLockSchema::V1, + base_manifest_sha256: self.tree.base_manifest_sha256.clone(), + base_descriptor_sha256: self.base_descriptor_sha256.clone(), + base_tier: self.tier, + arch: self.arch, + profile: self.tree.profile, + components: self + .pins + .iter() + .cloned() + .zip(self.archives.iter().cloned()) + .collect(), + producer_revision: producer_revision.to_owned(), + producer_version: env!("CARGO_PKG_VERSION").into(), + ext4_sha256, + verity_sha256, + verity_root_hash, + }; + Ok(MaterializedComponentRootfs { image, lock }) + } + + fn reverify_base(&self, base: &ComponentGraphBase<'_>) -> Result<(), RuntimeLayerStageError> { + let (descriptor, tier, manifest_sha256) = match base { + ComponentGraphBase::Production { + images, + compatibility, + } => { + images.retained_publisher_proof()?; + ( + compatibility.descriptor(), + ImageTrustTier::VerifiedRelease, + Sha256Hex::from_bytes(images.manifest_bytes()), + ) + } + ComponentGraphBase::Development { + compatibility, + evidence, + manifest_bytes, + kernel, + tar, + } => { + reverify_local_component_base( + compatibility, + evidence, + manifest_bytes, + kernel, + tar, + )?; + ( + compatibility.descriptor(), + ImageTrustTier::LocalDev, + Sha256Hex::from_bytes(manifest_bytes), + ) + } + }; + let descriptor_sha256 = match base { + ComponentGraphBase::Production { compatibility, .. } => { + Sha256Hex::from_bytes(compatibility.descriptor_bytes()) + } + ComponentGraphBase::Development { compatibility, .. } => { + Sha256Hex::from_bytes(compatibility.descriptor_bytes()) + } + }; + if self.tier != tier + || self.arch != descriptor.arch() + || self.tree.profile != descriptor.profile() + || self.tree.base_manifest_sha256 != manifest_sha256 + || self.base_descriptor_sha256 != descriptor_sha256 + || descriptor.image_set_manifest_sha256() != &manifest_sha256 + { + return Err(RuntimeLayerStageError::ComponentGraph); + } + Ok(()) + } + + fn reverify_components( + &self, + applications: &[VerifiedApplicationComponentInputs], + components: &[VerifiedComponentLayerInputs], + ) -> Result<(), RuntimeLayerStageError> { + if self.pins.len() != self.archives.len() + || self.pins.len() != applications.len() + components.len() + { + return Err(RuntimeLayerStageError::ComponentGraph); + } + for (pin, archive) in self.pins.iter().zip(&self.archives) { + if let Some(input) = applications + .iter() + .find(|input| &input.descriptor().manifest().reference == pin.reference()) + { + let descriptor = input.descriptor(); + let actual = &descriptor.manifest().application_component.archive; + if &Sha256Hex::from_bytes(descriptor.manifest_bytes()) != pin.manifest_sha256() + || actual.archive_sha256 != archive.sha256 + || actual.archive_size != archive.size + { + return Err(RuntimeLayerStageError::ComponentGraph); + } + input.reverify()?; + } else if let Some(input) = components + .iter() + .find(|input| input.descriptor().manifest().reference() == pin.reference()) + { + let descriptor = input.descriptor(); + let actual = &descriptor.manifest().component().archive; + if descriptor.manifest_sha256() != pin.manifest_sha256() + || actual.archive_sha256 != archive.sha256 + || actual.archive_size != archive.size + { + return Err(RuntimeLayerStageError::ComponentGraph); + } + input.reverify()?; + } else { + return Err(RuntimeLayerStageError::ComponentGraph); + } + } + Ok(()) + } +} + +fn measure_materialized_component( + image: &crate::rootfs::MaterializedExt4, +) -> Result<(Sha256Hex, Sha256Hex, Sha256Hex), RuntimeLayerStageError> { + let root = image + .verity_root_hash + .as_deref() + .ok_or(RuntimeLayerStageError::ComponentGraph)?; + let verity_root_hash = + Sha256Hex::new(root).map_err(|_| RuntimeLayerStageError::ComponentGraph)?; + let parent = image + .path + .parent() + .ok_or(RuntimeLayerStageError::ComponentGraph)?; + if std::fs::read(parent.join("rootfs.roothash"))? != format!("{root}\n").as_bytes() { + return Err(RuntimeLayerStageError::ComponentGraph); + } + let ext4_sha256 = Sha256Hex::new(&mvm_core::crypto::image_verify::sha256_file(&image.path)?) + .map_err(|_| RuntimeLayerStageError::ComponentGraph)?; + let verity_sha256 = Sha256Hex::new(&mvm_core::crypto::image_verify::sha256_file( + &parent.join("rootfs.verity"), + )?) + .map_err(|_| RuntimeLayerStageError::ComponentGraph)?; + Ok((ext4_sha256, verity_sha256, verity_root_hash)) +} diff --git a/crates/mvm-bundler/src/export.rs b/crates/mvm-bundler/src/export.rs index ce00521ebc..8890aebd23 100644 --- a/crates/mvm-bundler/src/export.rs +++ b/crates/mvm-bundler/src/export.rs @@ -186,6 +186,14 @@ fn stage_bundle_at( || (inputs.cli_boot_assets.is_some() && inputs.boot_assets.is_none()), "canonical Linux proof requires one selected CLI boot closure" ); + anyhow::ensure!( + inputs.local_development_base_proof.is_none() + || (inputs.canonical_linux_proof.is_none() + && inputs.local_build + && inputs.cli_boot_assets.is_some() + && inputs.local_composition.is_some()), + "local development base requires local composition, CLI runtime and no canonical proof" + ); let mut contents = BundleContents::default(); contents.push_file(KERNEL_NAME, ArtifactRole::Kernel, "kernel", inputs.vmlinux)?; @@ -207,7 +215,11 @@ fn stage_bundle_at( inputs.boot_assets.is_none(), "cannot mix CLI and legacy boot assets" ); - contents.push_cli_boot_assets(boot, inputs.arch_label)?; + contents.push_cli_boot_assets( + boot, + inputs.arch_label, + inputs.local_development_base_proof.as_ref(), + )?; } if let Some(proof) = &inputs.canonical_linux_proof { let boot = inputs @@ -453,6 +465,7 @@ impl BundleContents { &mut self, boot: &crate::CliBootAssetsInputs<'_>, arch: &str, + local: Option<&mvm_core::plan::bundle::LocalDevelopmentBaseProof>, ) -> Result<()> { use mvm_core::arch::GuestArch; use mvm_core::image_set::ImageSetManifest; @@ -474,15 +487,34 @@ impl BundleContents { other => anyhow::bail!("unknown CLI boot architecture {other}"), }; let manifest: ImageSetManifest = serde_json::from_slice(boot.manifest_bytes)?; - let member = select_cli_boot_kernel(&manifest, arch)?; - let [kernel] = member.artifacts.as_slice() else { - anyhow::bail!("CLI boot kernel must declare one artifact"); - }; let bundled = self .artifacts .iter() .find(|artifact| artifact.role == ArtifactRole::Kernel) .context("CLI boot kernel missing")?; + let member = if let Some(proof) = local { + anyhow::ensure!( + proof.manifest_artifact == BOOT_MANIFEST_NAME + && std::fs::canonicalize(&proof.composition_tar_path)? + == Path::new(&proof.composition_tar_path), + "local base source path is not canonical or manifest identity differs" + ); + mvm_core::plan::bundle::verify_local_development_source(proof) + .context("local composition source differs from its proof")?; + mvm_core::plan::bundle::select_local_development_base( + &manifest, + arch, + &bundled.sha256, + bundled.size_bytes, + proof, + )? + .0 + } else { + select_cli_boot_kernel(&manifest, arch)? + }; + let [kernel] = member.artifacts.as_slice() else { + anyhow::bail!("CLI boot kernel must declare one artifact"); + }; anyhow::ensure!( bundled.sha256 == kernel.sha256.as_str() && bundled.size_bytes == kernel.size, "CLI boot kernel differs from the original Linux image set" @@ -651,6 +683,9 @@ fn declarations_for( }, )); } + if let Some(proof) = &inputs.local_development_base_proof { + members.push(BundleMember::LocalDevelopmentBaseProof(proof.clone())); + } if let Some(composition) = &inputs.local_composition { members.push(BundleMember::LocalComposition(composition.clone())); } @@ -686,7 +721,8 @@ fn declarations_for( /// bundle stays readable by the oldest reader that understands it: 3 for /// plain declarations, 4 for historical boot assets, 5 for an environment /// descriptor, 6 for a portable CLI-runtime closure, and 7 for canonical -/// Linux publisher/revocation evidence; 8 binds local composition inputs. +/// Linux publisher/revocation evidence; 8 binds local composition inputs and 9 +/// binds the distinct same-host development base source. fn schema_for(members: &[BundleMember]) -> u32 { members .iter() @@ -880,6 +916,36 @@ mod tests { ); } + #[test] + fn local_source_proof_bumps_only_its_own_bundle_to_schema_nine() { + let composition = + BundleMember::LocalComposition(mvm_core::plan::bundle::LocalComposition { + lock_artifact: "composition-lock.json".into(), + layers: Vec::new(), + }); + let local = BundleMember::LocalDevelopmentBaseProof( + mvm_core::plan::bundle::LocalDevelopmentBaseProof { + manifest_artifact: BOOT_MANIFEST_NAME.into(), + composition_tar_sha256: "a".repeat(64), + composition_tar_size: 16, + composition_tar_path: "/local/base.tar.gz".into(), + }, + ); + assert_eq!(schema_for(std::slice::from_ref(&composition)), 8); + assert_eq!(schema_for(&[composition, local]), 9); + assert_eq!( + schema_for(&[BundleMember::CanonicalLinuxProof( + mvm_core::plan::bundle::CanonicalLinuxProof { + manifest_artifact: BOOT_MANIFEST_NAME.into(), + signature_bundle_artifact: LINUX_SIGNATURE_NAME.into(), + revocation_document_artifact: REVOCATIONS_NAME.into(), + revocation_signature_bundle_artifact: REVOCATIONS_SIGNATURE_NAME.into(), + }, + )]), + 7 + ); + } + #[test] fn sidecar_resolves_beside_the_rootfs() { assert_eq!( diff --git a/crates/mvm-bundler/src/export/canonical_tests.rs b/crates/mvm-bundler/src/export/canonical_tests.rs index cd4a2bde72..b49064761e 100644 --- a/crates/mvm-bundler/src/export/canonical_tests.rs +++ b/crates/mvm-bundler/src/export/canonical_tests.rs @@ -132,6 +132,7 @@ impl Fixture { overlay_roothash: "a".repeat(64), protocol_version: 2, min_supported_protocol_version: 2, + pr_test_pull_request: None, }; Self { directory, diff --git a/crates/mvm-bundler/src/inputs.rs b/crates/mvm-bundler/src/inputs.rs index 971b2b7663..7bb78a3bc3 100644 --- a/crates/mvm-bundler/src/inputs.rs +++ b/crates/mvm-bundler/src/inputs.rs @@ -53,6 +53,8 @@ pub struct BundleExportInputs<'a> { pub boot_assets: Option>, pub cli_boot_assets: Option>, pub canonical_linux_proof: Option>, + /// Same-host, measured source evidence; never a canonical publisher proof. + pub local_development_base_proof: Option, /// Path to the kernel image. pub vmlinux: &'a str, /// Path to the initrd, when the template boots with one. @@ -105,6 +107,7 @@ impl<'a> BundleExportInputs<'a> { boot_assets: None, cli_boot_assets: None, canonical_linux_proof: None, + local_development_base_proof: None, vmlinux, initrd: None, rootfs, diff --git a/crates/mvm-bundler/tests/boot_assets.rs b/crates/mvm-bundler/tests/boot_assets.rs index 271dc00d17..accd09876e 100644 --- a/crates/mvm-bundler/tests/boot_assets.rs +++ b/crates/mvm-bundler/tests/boot_assets.rs @@ -48,6 +48,7 @@ fn cli_boot_export_preserves_linux_identity_and_refuses_incomplete_or_mixed_memb overlay_roothash: "a".repeat(64), protocol_version: 2, min_supported_protocol_version: 2, + pr_test_pull_request: None, }; // Structural sealing only: synthetic runtime inputs must not pass release verification. let paths: Vec<_> = [ diff --git a/crates/mvm-cli/Cargo.toml b/crates/mvm-cli/Cargo.toml index 4e9fbc128e..a4263dce94 100644 --- a/crates/mvm-cli/Cargo.toml +++ b/crates/mvm-cli/Cargo.toml @@ -174,6 +174,12 @@ manifest-verify = [ "mvm-client/manifest-verify", "mvm-build/manifest-verify", ] +pr-test-guest-runtime = [ + "mvm-core/pr-test-guest-runtime", + "mvm-client/pr-test-guest-runtime", + "mvm-hostd/pr-test-guest-runtime", +] +pr-test-synthetic-base = ["mvm-build/pr-test-synthetic-base"] template-registry-s3 = ["mvm-runtime/template-registry-s3"] # Forwards to `mvm-runtime/test-support` + `mvm-client/test-support`: the # hermetic in-memory `MockBackend` and the `--hypervisor mock` fast paths in diff --git a/crates/mvm-cli/src/commands/bundle/export.rs b/crates/mvm-cli/src/commands/bundle/export.rs index c0a6e4aa45..fd1ef8ec5b 100644 --- a/crates/mvm-cli/src/commands/bundle/export.rs +++ b/crates/mvm-cli/src/commands/bundle/export.rs @@ -324,6 +324,7 @@ pub(in crate::commands) fn stage_template( boot_assets: None, cli_boot_assets: None, canonical_linux_proof: None, + local_development_base_proof: None, supplemental_artifacts: &[], local_composition: None, out: &export.out, diff --git a/crates/mvm-cli/src/commands/pack/inspect.rs b/crates/mvm-cli/src/commands/pack/inspect.rs index 067c063b1d..bf3b2ebde5 100644 --- a/crates/mvm-cli/src/commands/pack/inspect.rs +++ b/crates/mvm-cli/src/commands/pack/inspect.rs @@ -61,6 +61,14 @@ pub(super) fn run(reference_arg: &str, json: bool, verify_only: bool) -> Result< VerifiedRegistryManifest::Application(application) => { return inspect_application(&application, json, verify_only); } + VerifiedRegistryManifest::ApplicationComponent(_) => { + anyhow::bail!( + "portable application component inspection requires verified archive custody" + ); + } + VerifiedRegistryManifest::ComponentLayer(_) => { + anyhow::bail!("runtime/tool component inspection requires verified archive custody"); + } VerifiedRegistryManifest::RuntimeLayer(_) => { anyhow::bail!("runtime layer inspection requires verified archive custody"); } diff --git a/crates/mvm-cli/src/commands/tests.rs b/crates/mvm-cli/src/commands/tests.rs index 0372c393fd..54f23d4b82 100644 --- a/crates/mvm-cli/src/commands/tests.rs +++ b/crates/mvm-cli/src/commands/tests.rs @@ -4870,6 +4870,150 @@ fn composed_run_parses_one_command_and_refuses_other_boot_sources() { ); } +#[test] +fn composed_run_requires_an_explicit_complete_local_base_selection() { + let parsed = Cli::try_parse_from([ + "mvmctl", + "run", + "--compose", + "./pack.toml", + "--compose-base", + "local", + "--compose-base-manifest", + "./local-image-set.json", + "--compose-base-kernel", + "./vmlinux", + "--compose-base-tar", + "./rootfs.tar.gz", + ]) + .unwrap(); + let Commands::Run(exec::RunVerbArgs { sdk, .. }) = parsed.command else { + panic!("expected run command"); + }; + assert_eq!(sdk.compose_base, Some(exec::ComposeBase::Local)); + assert_eq!( + sdk.compose_base_manifest.as_deref(), + Some(std::path::Path::new("./local-image-set.json")) + ); + for args in [ + vec![ + "mvmctl", + "run", + "--compose", + "./pack.toml", + "--compose-base-manifest", + "./local-image-set.json", + ], + vec![ + "mvmctl", + "run", + "--compose-base", + "local", + "--compose-base-kernel", + "./vmlinux", + ], + ] { + assert!(Cli::try_parse_from(args).is_err()); + } + let parsed = Cli::try_parse_from([ + "mvmctl", + "run", + "--compose", + "./pack.toml", + "--compose-base", + "signed-default", + ]) + .unwrap(); + let Commands::Run(exec::RunVerbArgs { sdk, .. }) = parsed.command else { + panic!("expected run command"); + }; + assert_eq!(sdk.compose_base, Some(exec::ComposeBase::SignedDefault)); + let parsed = Cli::try_parse_from([ + "mvmctl", + "run", + "--compose", + "./pack.toml", + "--compose-base", + "local", + "--compose-base-manifest", + "./local-image-set.json", + "--compose-base-kernel", + "./vmlinux", + "--compose-base-tar", + "./rootfs.tar.gz", + "--prod", + ]) + .unwrap(); + let Commands::Run(exec::RunVerbArgs { machine, .. }) = parsed.command else { + panic!("expected run command"); + }; + assert!( + exec::validate_composition_request(&machine).is_err(), + "a local composition must never inherit a production request" + ); +} + +#[cfg(feature = "pr-test-guest-runtime")] +#[test] +fn pr_test_runtime_flags_are_explicit_and_require_the_complete_pair() { + let local = [ + "mvmctl", + "run", + "--compose", + "./pack.toml", + "--compose-base", + "local", + "--compose-base-manifest", + "./local-image-set.json", + "--compose-base-kernel", + "./vmlinux", + "--compose-base-tar", + "./rootfs.tar.gz", + ]; + for suffix in [ + vec!["--compose-pr-test-runtime-dir", "./quartet"], + vec!["--compose-pr-test-pull-request", "42"], + ] { + assert!(Cli::try_parse_from(local.iter().copied().chain(suffix)).is_err()); + } + let parsed = Cli::try_parse_from(local.iter().copied().chain([ + "--profile", + "dev", + "--compose-pr-test-runtime-dir", + "./quartet", + "--compose-pr-test-pull-request", + "42", + ])) + .unwrap(); + let Commands::Run(exec::RunVerbArgs { sdk, machine }) = parsed.command else { + panic!("expected run command"); + }; + assert_eq!( + sdk.compose_pr_test_runtime_dir.as_deref(), + Some(std::path::Path::new("./quartet")) + ); + assert_eq!(sdk.compose_pr_test_pull_request, Some(42)); + assert!(!machine.run.prod); +} + +#[cfg(not(feature = "pr-test-guest-runtime"))] +#[test] +fn ordinary_binary_does_not_parse_pr_test_runtime_flags() { + assert!( + Cli::try_parse_from([ + "mvmctl", + "run", + "--compose", + "./pack.toml", + "--compose-pr-test-runtime-dir", + "./quartet", + "--compose-pr-test-pull-request", + "42", + ]) + .is_err() + ); +} + #[test] fn run_transient_launch_plan_conflicts_with_argv() { let cli = Cli::try_parse_from([ diff --git a/crates/mvm-cli/src/commands/vm/exec.rs b/crates/mvm-cli/src/commands/vm/exec.rs index 65eada4115..803a7f7937 100644 --- a/crates/mvm-cli/src/commands/vm/exec.rs +++ b/crates/mvm-cli/src/commands/vm/exec.rs @@ -24,6 +24,10 @@ use super::host_signer::{host_signer_id, load_or_init}; pub(in crate::commands) mod application_source; mod composition; +mod composition_request; +pub(in crate::commands) use composition_request::SdkTransportArgs; +#[cfg(test)] +pub(in crate::commands) use composition_request::validate_composition_request; pub(in crate::commands) mod detect; mod image_source; pub(in crate::commands) mod positional_source; @@ -430,43 +434,6 @@ const PLAN_EXCLUDES: [&str; 12] = [ "secret", ]; -/// The SDK transport surface, carried by `mvmctl run` alone. -/// -/// These are kept out of [`RunArgs`] deliberately. `RunArgs` is flattened into -/// both `run` and `machine run` so a shared flag is declared exactly once; -/// `--mode`/`--dev`/`--ack-divergence` are not shared — `machine run` is the -/// beginner contract and has no business growing an SDK transport — so putting -/// them here is what lets the rest be flattened. -#[derive(ClapArgs, Debug, Clone, Default)] -pub(in crate::commands) struct SdkTransportArgs { - /// Assemble a signed, digest-pinned pack recipe and run its entrypoint. - #[arg(long = "compose", value_name = "PACK.TOML", conflicts_with_all = ["mode", "dev"])] - pub compose: Option, - /// SDK transport mode for `mvmctl run`. - /// - /// - `--mode plan`: synthesize an ExecutionPlan per Sandbox call - /// and route through `mvm_hostd::supervisor::admit_for_run`; no - /// microVM ever boots. - /// - `--mode live`: spawn the user's script with `MVM_SDK_MODE=live` - /// so the SDK shells each `Sandbox` operation to existing - /// `mvmctl` verbs against a real microVM. - /// - `--mode record` redirects users to `mvmctl dev compile` (where - /// record is the default mode). - /// - /// When unset, the verb behaves as a transient-sandbox runner - /// over the trailing argv. - #[arg(long = "mode", value_enum)] - pub mode: Option, - /// Friendly alias for `--mode live`. - #[arg(long = "dev", conflicts_with_all = ["prod", "mode"])] - pub dev: bool, - /// Acknowledge a divergence class on the plan-mode admission - /// path (repeatable). Unacknowledged divergence refuses - /// admission: what you previewed is not what would ship. - #[arg(long = "ack-divergence", value_name = "KIND")] - pub ack_divergence: Vec, -} - /// `mvmctl run` — `machine run`, plus the SDK transport only `run` carries. #[derive(ClapArgs, Debug, Clone, Default)] pub(in crate::commands) struct RunVerbArgs { @@ -476,6 +443,12 @@ pub(in crate::commands) struct RunVerbArgs { pub sdk: SdkTransportArgs, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub(in crate::commands) enum ComposeBase { + SignedDefault, + Local, +} + /// SDK transport modes for `mvmctl run`. Mirrors the `Mode` enum on /// `mvmctl dev compile` but specialises the rejection messages to point /// users at the right verb when they pick the wrong default. @@ -586,15 +559,7 @@ pub(in crate::commands) fn run_verb( mut args: RunVerbArgs, cfg: &MvmConfig, ) -> Result<()> { - let composition = if let Some(path) = args.sdk.compose.take() { - validate_composition_request(&args.machine)?; - let built = composition::prepare(&path)?; - args.machine.run.manifest = Some(built.archive().to_string_lossy().into_owned()); - args.machine.entrypoint = true; - Some(built) - } else { - None - }; + let composition = composition_request::prepare_composition(&mut args)?; let run = &mut args.machine.run; // Checked before the script is moved into argv below: a script path never // looks like a flag, so it would hide a misplaced one behind it. @@ -613,31 +578,6 @@ pub(in crate::commands) fn run_verb( result } -pub(in crate::commands) fn validate_composition_request( - machine: &crate::commands::machine::MachineRunArgs, -) -> Result<()> { - let run = &machine.run; - anyhow::ensure!( - run.source.is_none() - && run.application.is_none() - && run.image.is_none() - && run.manifest.is_none() - && run.flake.is_none() - && run.deployment.is_none() - && run.runtime.is_none() - && !run.runtime_pack - && !run.prod - && !run.dry_run - && run.launch_plan.is_none() - && run.argv.is_empty() - && !machine.interactive() - && !machine.persistent() - && !machine.attach, - "--compose requires an unambiguous transient development run without another boot source or command" - ); - Ok(()) -} - /// Run a transient workload through the admitted path from the instant launch /// resolution began, optionally overriding lookup with a content-pinned source. pub(in crate::commands) fn run_secure_started( @@ -2133,10 +2073,10 @@ mod tests { /// shared `RunArgs` separately, so each case names which half it exercises. fn sdk(mode: Option, dev: bool) -> SdkTransportArgs { SdkTransportArgs { - compose: None, mode, dev, ack_divergence: Vec::new(), + ..Default::default() } } diff --git a/crates/mvm-cli/src/commands/vm/exec/composition.rs b/crates/mvm-cli/src/commands/vm/exec/composition.rs index 3382f55bc3..3f8f53f320 100644 --- a/crates/mvm-cli/src/commands/vm/exec/composition.rs +++ b/crates/mvm-cli/src/commands/vm/exec/composition.rs @@ -5,7 +5,9 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result, ensure}; use mvm_build::published_image_set::PublishedImageSet; -use mvm_build::runtime_layer::compose_authored_candidate; +use mvm_build::runtime_layer::{ + ResolvedComposableLock, compose_authored_candidate, compose_local_authored_candidate, +}; use mvm_client::bundle::{BundleExportInputs, BundleSigner, HostBundleSigner, PostureInputs}; use mvm_client::portable_boot_assets::PreparedPortableBootAssets; use mvm_contract::pack_spec::PackTarget; @@ -38,7 +40,31 @@ impl PreparedComposition { } } -pub(super) fn prepare(path: &Path) -> Result { +pub(super) enum Base<'a> { + SignedDefault, + #[cfg(feature = "pr-test-synthetic-base")] + Synthetic { + fixture: mvm_build::pr_test_synthetic_base::PrTestSyntheticBase, + #[cfg(feature = "pr-test-guest-runtime")] + pr_test: PrTestInput<'a>, + }, + Local { + manifest: &'a Path, + kernel: &'a Path, + tar: &'a Path, + #[cfg(feature = "pr-test-guest-runtime")] + pr_test: Option>, + }, +} + +#[cfg(feature = "pr-test-guest-runtime")] +#[derive(Clone, Copy)] +pub(super) struct PrTestInput<'a> { + pub directory: &'a Path, + pub pull_request: u64, +} + +pub(super) fn prepare(path: &Path, base: Base<'_>) -> Result { let metadata = std::fs::symlink_metadata(path)?; ensure!( metadata.file_type().is_file() && metadata.len() <= 64 * 1024, @@ -78,9 +104,72 @@ pub(super) fn prepare(path: &Path) -> Result { }) .collect::>()? }; - let images = PublishedImageSet::acquire_workload()?; let profile = WorkloadImageProfile::DefaultTenant; - let tree = compose_authored_candidate(&spec, &images, profile, &ordered)?; + #[cfg(feature = "pr-test-guest-runtime")] + let pr_test = match &base { + Base::SignedDefault => None, + #[cfg(feature = "pr-test-synthetic-base")] + Base::Synthetic { pr_test, .. } => Some(*pr_test), + Base::Local { pr_test, .. } => *pr_test, + }; + #[cfg(feature = "pr-test-synthetic-base")] + let synthetic = match &base { + Base::Synthetic { fixture, .. } => Some(fixture), + Base::SignedDefault | Base::Local { .. } => None, + }; + let local = match &base { + Base::SignedDefault => None, + #[cfg(feature = "pr-test-synthetic-base")] + Base::Synthetic { fixture, .. } => { + let verified = fixture.verify(arch)?; + Some(( + verified.manifest_path, + verified.manifest, + verified.kernel, + verified.tar, + )) + } + Base::Local { + manifest, + kernel, + tar, + .. + } => { + let metadata = std::fs::symlink_metadata(manifest)?; + ensure!( + metadata.file_type().is_file() + && metadata.len() <= mvm_core::plan::bundle::MAX_IMAGE_SET_MANIFEST_BYTES, + "local base manifest must be a bounded regular file" + ); + for input in [kernel, tar] { + ensure!( + std::fs::symlink_metadata(input)?.file_type().is_file(), + "local base kernel and tar must be regular, not links" + ); + } + Some(( + manifest.to_path_buf(), + std::fs::read(manifest)?, + std::fs::canonicalize(kernel)?, + std::fs::canonicalize(tar)?, + )) + } + }; + let images = if local.is_none() { + Some(PublishedImageSet::acquire_workload()?) + } else { + None + }; + let tree = if let Some((_, manifest, kernel, tar)) = &local { + compose_local_authored_candidate(&spec, manifest, kernel, tar, profile, &ordered)? + } else { + compose_authored_candidate( + &spec, + images.as_ref().context("signed base was not acquired")?, + profile, + &ordered, + )? + }; let prepared = prepare_composable_application(&spec, directory)?; prepared.reverify()?; let archive_sha = Sha256Hex::new(prepared.archive_sha256())?; @@ -89,43 +178,222 @@ pub(super) fn prepare(path: &Path) -> Result { let lock = application.development_lock(&spec, &source_sha, env!("MVM_SOURCE_COMMIT"))?; let lock_bytes = serde_json::to_vec(&lock)?; let lock_sha = Sha256Hex::from_bytes(&lock_bytes); + let filesystem_identity = lock.filesystem_identity()?; + #[cfg(feature = "pr-test-guest-runtime")] + let pr_test_proof = if let Some(input) = pr_test { + let name = mvm_build::guest_bins::guest_bins_archive_name(env!("CARGO_PKG_VERSION")); + let archive = input.directory.join(&name); + let proof = mvm_core::protocol::runtime_release::PrTestRuntimeProof { + input: mvm_core::protocol::runtime_release::RuntimeReleaseProof { + archive: archive.clone(), + checksum: input.directory.join(format!("{name}.sha256")), + signature_bundle: input.directory.join(format!("{name}.bundle")), + archive_sha256: mvm_core::crypto::image_verify::sha256_file(&archive)?, + version: env!("CARGO_PKG_VERSION").into(), + arch, + }, + pull_request: input.pull_request, + }; + mvm_build::guest_runtime::evidence::verify_pr_test_runtime_proof( + &proof, + env!("CARGO_PKG_VERSION"), + arch, + )?; + Some(proof) + } else { + None + }; + // Local source custody is path-bound, even when two paths have identical bytes. + let cache_sha = if let Some((_, _, _, tar)) = &local { + #[cfg(feature = "pr-test-guest-runtime")] + let ordinary = if let Some(proof) = &pr_test_proof { + Sha256Hex::from_bytes( + &[ + lock_bytes.as_slice(), + tar.as_os_str().as_encoded_bytes(), + &proof.pull_request.to_be_bytes(), + proof.input.archive_sha256.as_bytes(), + ] + .concat(), + ) + } else { + Sha256Hex::from_bytes( + &[lock_bytes.as_slice(), tar.as_os_str().as_encoded_bytes()].concat(), + ) + }; + #[cfg(not(feature = "pr-test-guest-runtime"))] + let ordinary = Sha256Hex::from_bytes( + &[lock_bytes.as_slice(), tar.as_os_str().as_encoded_bytes()].concat(), + ); + #[cfg(feature = "pr-test-synthetic-base")] + { + if let Some(fixture) = synthetic { + Sha256Hex::from_bytes( + &[ + b"pr-test-synthetic-base".as_slice(), + ordinary.as_str().as_bytes(), + &fixture.pull_request().to_be_bytes(), + ] + .concat(), + ) + } else { + ordinary + } + } + #[cfg(not(feature = "pr-test-synthetic-base"))] + ordinary + } else { + lock_sha.clone() + }; let cache = PathBuf::from(mvm_core::config::mvm_cache_dir()).join("compositions"); mvm_core::config::create_private_dir(&cache)?; - let target = cache.join(format!("{}.mvmpkg", lock_sha.as_str())); + let target = cache.join(format!("{}.mvmpkg", cache_sha.as_str())); let reverify_inputs = || -> Result<()> { prepared.reverify()?; + #[cfg(feature = "pr-test-synthetic-base")] + if let Some(fixture) = synthetic { + let current = fixture.verify(arch)?; + let (_, original, kernel, tar) = local + .as_ref() + .context("synthetic base selection was lost")?; + ensure!( + current.manifest == *original && current.kernel == *kernel && current.tar == *tar, + "PR synthetic base selection changed before use" + ); + } + #[cfg(feature = "pr-test-guest-runtime")] + if let Some(proof) = &pr_test_proof { + mvm_build::guest_runtime::evidence::verify_pr_test_runtime_proof( + proof, + env!("CARGO_PKG_VERSION"), + arch, + )?; + } for input in &inputs { mvm_core::registry_pack::runtime_layer::require_offline_runtime_policy(input)?; } - images.retained_publisher_proof()?; + if let Some((manifest_path, original, kernel, tar)) = &local { + let metadata = std::fs::symlink_metadata(manifest_path)?; + ensure!( + metadata.file_type().is_file() + && metadata.len() == original.len() as u64 + && std::fs::read(manifest_path)? == *original, + "local base manifest changed before signing" + ); + let declared: mvm_core::image_set::ImageSetManifest = serde_json::from_slice(original)?; + mvm_core::image_set::check_workload_protocol_compatibility( + &declared.compatibility, + &mvm_build::stage0_kernel::current_image_set_protocol_support(), + )?; + let (selected_kernel, selected_tar) = + mvm_core::plan::bundle::select_local_development_source(&declared, arch, profile)?; + for (file, artifact) in [(kernel, selected_kernel), (tar, selected_tar)] { + ensure!( + std::fs::symlink_metadata(file)?.file_type().is_file() + && std::fs::metadata(file)?.len() == artifact.size + && mvm_core::crypto::image_verify::sha256_file(file)? + == artifact.sha256.as_str(), + "selected local base input changed before signing" + ); + } + } else { + images + .as_ref() + .context("signed base was not acquired")? + .retained_publisher_proof()?; + } Ok(()) }; if std::fs::symlink_metadata(&target).is_ok() { reverify_inputs()?; - verify_cached_bundle(&target, &lock_bytes)?; + verify_cached_bundle( + &target, + &lock_bytes, + local.as_ref().map(|(_, _, _, tar)| tar.as_path()), + #[cfg(feature = "pr-test-guest-runtime")] + pr_test_proof + .as_ref() + .map(|proof| (proof.pull_request, proof.input.archive_sha256.as_str())), + #[cfg(not(feature = "pr-test-guest-runtime"))] + None, + )?; eprintln!( "[mvm] authenticated cached composition {}", - lock_sha.as_str() + cache_sha.as_str() ); return Ok(PreparedComposition { archive: target }); } - let boot = PreparedPortableBootAssets::acquire(arch)?; - ensure!( - boot.image_set_manifest_sha256() == lock.base_manifest_sha256(), - "composition and signed CLI boot closure selected different Linux roots" - ); + #[cfg(feature = "pr-test-guest-runtime")] + let boot = if let Some(input) = pr_test { + PreparedPortableBootAssets::acquire_pr_test_local( + arch, + input.directory, + input.pull_request, + )? + } else if local.is_some() { + PreparedPortableBootAssets::acquire_local(arch)? + } else { + PreparedPortableBootAssets::acquire(arch)? + }; + #[cfg(not(feature = "pr-test-guest-runtime"))] + let boot = if local.is_some() { + PreparedPortableBootAssets::acquire_local(arch)? + } else { + PreparedPortableBootAssets::acquire(arch)? + }; + if local.is_none() { + ensure!( + boot.image_set_manifest_sha256() == lock.base_manifest_sha256(), + "composition and signed CLI boot closure selected different Linux roots" + ); + } + reverify_inputs()?; + let prior = previous_filesystem_bundle(&cache, &filesystem_identity)?; + let reused = prior + .map(|(verified, archive)| verified.extract_rootfs_with_verity(archive)) + .transpose()?; let directory = tempfile::tempdir()?; mvm_core::config::create_private_dir(directory.path())?; - let image = directory.path().join("rootfs.ext4"); - let materialized = application.materialize_candidate(&image)?; - let roothash = materialized - .verity_root_hash - .as_deref() - .context("composed rootfs did not produce dm-verity")?; + let image = reused.as_ref().map_or_else( + || directory.path().join("rootfs.ext4"), + |root| root.rootfs.clone(), + ); + let (roothash, verity_path) = if let Some(root) = &reused { + (root.roothash.clone(), root.verity.clone()) + } else { + let materialized = application.materialize_candidate(&image)?; + ( + materialized + .verity_root_hash + .context("composed rootfs did not produce dm-verity")?, + directory.path().join("rootfs.verity"), + ) + }; let output_sha256 = mvm_core::crypto::image_verify::sha256_file(&image)?; - images - .workload_sidecar(&spec.identity.name, spec.entrypoint.clone()) - .write_to_dir(directory.path())?; + let mut sidecar = mvm_build::builder_vm::GuestSidecar { + name: spec.identity.name.clone(), + accessible: false, + sealed: true, + entrypoint_kind: "command".to_owned(), + entrypoint_argv: spec.entrypoint.clone(), + init_system: "cli-initramfs".to_owned(), + expected_boot_ms: 300, + agent_binary: String::new(), + rootless_entrypoint: true, + hypervisor: "firecracker".to_owned(), + overlay_aware: true, + runtime_lean: true, + image_tag: String::new(), + source: String::new(), + built_at: String::new(), + protocol_version: 0, + generator_rev: String::new(), + libc: Default::default(), + }; + if let Some(images) = &images { + sidecar = images.workload_sidecar(&spec.identity.name, spec.entrypoint.clone()); + } + sidecar.write_to_dir(directory.path())?; let (member, evidence) = retain_evidence(directory.path(), &lock_bytes, &ordered)?; let supplemental: Vec<(&str, &Path)> = evidence .iter() @@ -149,10 +417,11 @@ pub(super) fn prepare(path: &Path) -> Result { capabilities: Vec::new(), services: Vec::new(), }; - let verity = std::fs::read(directory.path().join("rootfs.verity"))?; + let verity = std::fs::read(&verity_path)?; let arch_label = arch.to_string(); - let kernel = boot - .kernel_path() + let kernel = local + .as_ref() + .map_or_else(|| boot.kernel_path(), |(_, _, kernel, _)| kernel.as_path()) .to_str() .context("kernel path is not UTF-8")?; let rootfs = image.to_str().context("rootfs path is not UTF-8")?; @@ -162,7 +431,7 @@ pub(super) fn prepare(path: &Path) -> Result { .context("initramfs path is not UTF-8")?; let mut export = BundleExportInputs::new(kernel, rootfs, &arch_label, &target) .initrd(initrd) - .verity(&verity, roothash) + .verity(&verity, &roothash) .profile("dev") .resources(spec.resources.cpu_cores.into(), spec.resources.memory_mb) .posture(PostureInputs::new(AgentProfile::Dev)) @@ -177,7 +446,35 @@ pub(super) fn prepare(path: &Path) -> Result { .local_build(); export.local_composition = Some(member); export.supplemental_artifacts = &supplemental; - let signed = boot.stage(&export, &HostBundleSigner::load()?)?; + reverify_inputs()?; + let signed = if let Some((_, manifest_bytes, _, tar)) = &local { + let source: mvm_core::image_set::ImageSetManifest = serde_json::from_slice(manifest_bytes)?; + let member = source + .members + .iter() + .find(|member| { + member.role == mvm_core::image_set::ImageSetRole::WorkloadRootfs(profile) + && member.target == mvm_core::image_set::MemberTarget::Arch(arch) + }) + .context("selected local base root is missing")?; + let tar_artifact = member + .artifacts + .iter() + .find(|artifact| artifact.format == mvm_core::image_set::ArtifactFormat::TarGz) + .context("selected local composition tar is missing")?; + let proof = mvm_core::plan::bundle::LocalDevelopmentBaseProof { + manifest_artifact: "boot-image-set.json".to_owned(), + composition_tar_sha256: tar_artifact.sha256.as_str().to_owned(), + composition_tar_size: tar_artifact.size, + composition_tar_path: tar + .to_str() + .context("local tar path is not UTF-8")? + .to_owned(), + }; + boot.stage_local(&export, &HostBundleSigner::load()?, manifest_bytes, proof)? + } else { + boot.stage(&export, &HostBundleSigner::load()?)? + }; reverify_inputs()?; ensure!( mvm_core::crypto::image_verify::sha256_file(&image)? == output_sha256, @@ -189,15 +486,35 @@ pub(super) fn prepare(path: &Path) -> Result { } reverify_inputs()?; } - verify_cached_bundle(&target, &lock_bytes)?; - eprintln!( - "[mvm] assembled and cached composition {}", - lock_sha.as_str() - ); + reverify_inputs()?; + record_filesystem_bundle(&cache, &filesystem_identity, &cache_sha)?; + verify_cached_bundle( + &target, + &lock_bytes, + local.as_ref().map(|(_, _, _, tar)| tar.as_path()), + #[cfg(feature = "pr-test-guest-runtime")] + pr_test_proof + .as_ref() + .map(|proof| (proof.pull_request, proof.input.archive_sha256.as_str())), + #[cfg(not(feature = "pr-test-guest-runtime"))] + None, + )?; + if reused.is_some() { + eprintln!( + "[mvm] reused verified filesystem {} for composition {}", + filesystem_identity.as_str(), + cache_sha.as_str() + ); + } else { + eprintln!( + "[mvm] assembled and cached composition {}", + cache_sha.as_str() + ); + } Ok(PreparedComposition { archive: target }) } -fn verify_cached_bundle(path: &Path, lock: &[u8]) -> Result<()> { +fn verified_local_bundle(path: &Path) -> Result { ensure!( std::fs::symlink_metadata(path)?.file_type().is_file(), "composed cache entry must be a regular file" @@ -214,6 +531,92 @@ fn verify_cached_bundle(path: &Path, lock: &[u8]) -> Result<()> { bundle_origin(&verified.manifest, &id, trust.release()) == BundleOrigin::LocalBuild, "composed cache entry is not a host-signed local build" ); + Ok(verified) +} + +fn previous_filesystem_bundle( + cache: &Path, + identity: &Sha256Hex, +) -> Result> { + let index = cache.join(format!("rootfs-{}.ref", identity.as_str())); + let metadata = match std::fs::symlink_metadata(&index) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), + }; + ensure!( + metadata.file_type().is_file() && metadata.len() <= 128, + "filesystem cache index must be a bounded regular file" + ); + let entry = std::fs::read_to_string(&index)?; + let digest = entry + .strip_suffix(".mvmpkg\n") + .context("filesystem cache index has an invalid archive name")?; + let digest = Sha256Hex::new(digest.to_owned())?; + let archive = cache.join(format!("{}.mvmpkg", digest.as_str())); + let verified = verified_local_bundle(&archive)?; + let member = verified + .manifest + .members + .iter() + .find_map(|member| match member { + BundleMember::LocalComposition(member) => Some(member), + _ => None, + }) + .context("filesystem cache source has no signed composition lock")?; + let artifact = verified + .manifest + .find_by_name(&member.lock_artifact) + .context("filesystem cache source has no composition lock")?; + ensure!( + artifact.role == mvm_core::plan::bundle::ArtifactRole::Other, + "filesystem cache source lock has the wrong artifact role" + ); + let original: ResolvedComposableLock = + serde_json::from_slice(&verified.read_small_verified_member( + std::fs::File::open(&archive)?, + &member.lock_artifact, + 1024 * 1024, + )?)?; + ensure!( + original.filesystem_identity()? == *identity, + "filesystem cache source differs from today's ordered filesystem inputs" + ); + Ok(Some((verified, std::fs::File::open(archive)?))) +} + +fn record_filesystem_bundle(cache: &Path, identity: &Sha256Hex, archive: &Sha256Hex) -> Result<()> { + let index = cache.join(format!("rootfs-{}.ref", identity.as_str())); + if std::fs::symlink_metadata(&index).is_ok() { + ensure!( + previous_filesystem_bundle(cache, identity)?.is_some(), + "existing filesystem cache index has no verified source" + ); + return Ok(()); + } + let mut pending = tempfile::NamedTempFile::new_in(cache)?; + std::io::Write::write_all( + &mut pending, + format!("{}.mvmpkg\n", archive.as_str()).as_bytes(), + )?; + pending.as_file().sync_all()?; + match pending.persist_noclobber(&index) { + Ok(_) => Ok(()), + Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => { + previous_filesystem_bundle(cache, identity)?; + Ok(()) + } + Err(error) => Err(error.error.into()), + } +} + +fn verify_cached_bundle( + path: &Path, + lock: &[u8], + local_tar: Option<&Path>, + pr_test_expected: Option<(u64, &str)>, +) -> Result<()> { + let verified = verified_local_bundle(path)?; let member = verified .manifest .members @@ -236,6 +639,43 @@ fn verify_cached_bundle(path: &Path, lock: &[u8]) -> Result<()> { )? == lock, "cached composition differs from today's resolved signed inputs" ); + let selected_tar = verified + .manifest + .members + .iter() + .find_map(|member| match member { + BundleMember::LocalDevelopmentBaseProof(proof) => { + Some(proof.composition_tar_path.as_str()) + } + _ => None, + }); + ensure!( + selected_tar.map(Path::new) == local_tar, + "cached composition selected a different local base source" + ); + let selected_pr_test = verified + .manifest + .members + .iter() + .find_map(|member| match member { + BundleMember::EmbeddedCliBootAssets(runtime) => Some(runtime), + _ => None, + }); + ensure!( + selected_pr_test.and_then(|runtime| runtime.pr_test_pull_request) + == pr_test_expected.map(|(pull_request, _)| pull_request), + "cached composition selected a different PR test runtime" + ); + if let Some((_, digest)) = pr_test_expected { + let runtime = selected_pr_test.context("cached PR test runtime declaration missing")?; + ensure!( + verified + .manifest + .find_by_name(&runtime.archive_artifact) + .is_some_and(|artifact| artifact.sha256 == digest), + "cached composition selected a different signed runtime archive" + ); + } Ok(()) } @@ -343,3 +783,43 @@ fn retain_evidence( files, )) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn filesystem_index_is_only_a_hint_not_an_archive_authority() { + let cache = tempfile::tempdir().expect("private cache"); + let identity = Sha256Hex::from_bytes(b"ordered filesystem inputs"); + let index = cache + .path() + .join(format!("rootfs-{}.ref", identity.as_str())); + assert!( + previous_filesystem_bundle(cache.path(), &identity) + .expect("absent cache entry is a miss") + .is_none() + ); + for value in [ + "../other.mvmpkg\n".to_owned(), + "A".repeat(64), + format!("{}.mvmpkg\n", "a".repeat(64)), + ] { + std::fs::write(&index, &value).expect("write invalid hint"); + assert!( + previous_filesystem_bundle(cache.path(), &identity).is_err(), + "untrusted hint {value:?} must not authorize reuse" + ); + } + #[cfg(unix)] + { + use std::os::unix::fs::symlink; + std::fs::remove_file(&index).expect("remove invalid hint"); + symlink(cache.path().join("elsewhere"), &index).expect("link cache hint"); + assert!( + previous_filesystem_bundle(cache.path(), &identity).is_err(), + "a linked index cannot authorize a cached rootfs" + ); + } + } +} diff --git a/crates/mvm-cli/src/commands/vm/exec/composition_request.rs b/crates/mvm-cli/src/commands/vm/exec/composition_request.rs new file mode 100644 index 0000000000..e4820268fa --- /dev/null +++ b/crates/mvm-cli/src/commands/vm/exec/composition_request.rs @@ -0,0 +1,293 @@ +//! Run-only composition and SDK transport flags, separate from machine run. + +#[cfg(feature = "pr-test-synthetic-base")] +use anyhow::Context; +use anyhow::Result; +use clap::Args as ClapArgs; +#[cfg(feature = "pr-test-synthetic-base")] +use std::path::Path; +use std::path::PathBuf; + +#[cfg(any(feature = "pr-test-guest-runtime", feature = "pr-test-synthetic-base"))] +use super::RunProfile; +use super::{ComposeBase, RunMode, RunVerbArgs, composition}; + +/// The SDK transport surface, carried by `mvmctl run` alone. +/// +/// These are kept out of [`super::RunArgs`] deliberately. `RunArgs` is flattened into +/// both `run` and `machine run` so a shared flag is declared exactly once; +/// `--mode`/`--dev`/`--ack-divergence` are not shared — `machine run` is the +/// beginner contract and has no business growing an SDK transport — so putting +/// them here is what lets the rest be flattened. +#[derive(ClapArgs, Debug, Clone, Default)] +pub(in crate::commands) struct SdkTransportArgs { + /// Assemble a signed, digest-pinned pack recipe and run its entrypoint. + #[arg(long = "compose", value_name = "PACK.TOML", conflicts_with_all = ["mode", "dev"])] + pub compose: Option, + /// Select the locked signed default or an explicitly supplied local base. + #[arg(long = "compose-base", value_enum, requires = "compose")] + pub compose_base: Option, + /// Local schema-3 image-set manifest declaring the selected kernel and tar. + #[arg(long = "compose-base-manifest", requires_all = ["compose", "compose_base_kernel", "compose_base_tar"])] + pub compose_base_manifest: Option, + /// Kernel declared by the local image-set manifest. + #[arg(long = "compose-base-kernel", requires_all = ["compose", "compose_base_manifest", "compose_base_tar"])] + pub compose_base_kernel: Option, + /// Composition tar declared by the local image-set manifest (not its ext4). + #[arg(long = "compose-base-tar", requires_all = ["compose", "compose_base_manifest", "compose_base_kernel"])] + pub compose_base_tar: Option, + /// Test-only, keyless-signed PR runtime quartet directory; requires local development. + #[cfg(feature = "pr-test-guest-runtime")] + #[arg(long = "compose-pr-test-runtime-dir", requires_all = ["compose", "compose_pr_test_pull_request"])] + pub compose_pr_test_runtime_dir: Option, + /// Exact PR whose workflow signed the runtime input, never a release identity. + #[cfg(feature = "pr-test-guest-runtime")] + #[arg(long = "compose-pr-test-pull-request", requires_all = ["compose", "compose_pr_test_runtime_dir"])] + pub compose_pr_test_pull_request: Option, + /// SDK transport mode for `mvmctl run`. + /// + /// - `--mode plan`: synthesize an ExecutionPlan per Sandbox call + /// and route through `mvm_hostd::supervisor::admit_for_run`; no + /// microVM ever boots. + /// - `--mode live`: spawn the user's script with `MVM_SDK_MODE=live` + /// so the SDK shells each `Sandbox` operation to existing + /// `mvmctl` verbs against a real microVM. + /// - `--mode record` redirects users to `mvmctl dev compile` (where + /// record is the default mode). + /// + /// When unset, the verb behaves as a transient-sandbox runner + /// over the trailing argv. + #[arg(long = "mode", value_enum)] + pub mode: Option, + /// Friendly alias for `--mode live`. + #[arg(long = "dev", conflicts_with_all = ["prod", "mode"])] + pub dev: bool, + /// Acknowledge a divergence class on the plan-mode admission + /// path (repeatable). Unacknowledged divergence refuses + /// admission: what you previewed is not what would ship. + #[arg(long = "ack-divergence", value_name = "KIND")] + pub ack_divergence: Vec, +} + +pub(in crate::commands) fn validate_composition_request( + machine: &crate::commands::machine::MachineRunArgs, +) -> Result<()> { + let run = &machine.run; + anyhow::ensure!( + run.source.is_none() + && run.application.is_none() + && run.image.is_none() + && run.manifest.is_none() + && run.flake.is_none() + && run.deployment.is_none() + && run.runtime.is_none() + && !run.runtime_pack + && !run.prod + && !run.dry_run + && run.launch_plan.is_none() + && run.argv.is_empty() + && !machine.interactive() + && !machine.persistent() + && !machine.attach, + "--compose requires an unambiguous transient development run without another boot source or command" + ); + Ok(()) +} + +pub(super) fn prepare_composition( + args: &mut RunVerbArgs, +) -> Result> { + let Some(path) = args.sdk.compose.take() else { + return Ok(None); + }; + validate_composition_request(&args.machine)?; + #[cfg(feature = "pr-test-synthetic-base")] + let synthetic = pr_test_base_input( + std::env::var_os("MVM_PR_TEST_BASE_DIR"), + std::env::var_os("MVM_PR_TEST_PULL_REQUEST"), + )?; + #[cfg(feature = "pr-test-synthetic-base")] + if synthetic.is_some() { + anyhow::ensure!( + args.sdk.compose_base.is_none() + && args.sdk.compose_base_manifest.is_none() + && args.sdk.compose_base_kernel.is_none() + && args.sdk.compose_base_tar.is_none() + && args.machine.run.profile == RunProfile::Dev + && !args.machine.run.prod, + "PR synthetic base requires plain --compose and --profile dev" + ); + } + let base = match args.sdk.compose_base { + Some(ComposeBase::Local) => { + let (Some(manifest), Some(kernel), Some(tar)) = ( + args.sdk.compose_base_manifest.as_deref(), + args.sdk.compose_base_kernel.as_deref(), + args.sdk.compose_base_tar.as_deref(), + ) else { + anyhow::bail!( + "--compose-base local requires --compose-base-manifest, --compose-base-kernel and --compose-base-tar" + ); + }; + #[cfg(feature = "pr-test-guest-runtime")] + let pr_test = match ( + args.sdk.compose_pr_test_runtime_dir.as_deref(), + args.sdk.compose_pr_test_pull_request, + ) { + (Some(directory), Some(pull_request)) => { + anyhow::ensure!( + pull_request > 0 + && args.machine.run.profile == RunProfile::Dev + && !args.machine.run.prod, + "PR test runtime requires --compose-base local, --profile dev and no --prod" + ); + Some(composition::PrTestInput { + directory, + pull_request, + }) + } + (None, None) => None, + _ => anyhow::bail!( + "PR test runtime requires both the quartet directory and pull request" + ), + }; + composition::Base::Local { + manifest, + kernel, + tar, + #[cfg(feature = "pr-test-guest-runtime")] + pr_test, + } + } + Some(ComposeBase::SignedDefault) | None => { + #[cfg(feature = "pr-test-guest-runtime")] + { + #[cfg(feature = "pr-test-synthetic-base")] + let synthetic_selected = synthetic.is_some(); + #[cfg(not(feature = "pr-test-synthetic-base"))] + let synthetic_selected = false; + if !synthetic_selected { + anyhow::ensure!( + args.sdk.compose_pr_test_runtime_dir.is_none() + && args.sdk.compose_pr_test_pull_request.is_none(), + "PR test runtime requires --compose-base local or a PR synthetic base" + ); + } + } + anyhow::ensure!( + args.sdk.compose_base_manifest.is_none() + && args.sdk.compose_base_kernel.is_none() + && args.sdk.compose_base_tar.is_none(), + "local base paths require --compose-base local" + ); + composition::Base::SignedDefault + } + }; + #[cfg(feature = "pr-test-synthetic-base")] + let base = if let Some(fixture) = synthetic { + #[cfg(feature = "pr-test-guest-runtime")] + let pr_test = pr_test_runtime_for_fixture(&args.sdk, &fixture)?; + composition::Base::Synthetic { + fixture, + #[cfg(feature = "pr-test-guest-runtime")] + pr_test, + } + } else { + base + }; + let built = composition::prepare(&path, base)?; + args.machine.run.manifest = Some(built.archive().to_string_lossy().into_owned()); + args.machine.entrypoint = true; + Ok(Some(built)) +} + +#[cfg(feature = "pr-test-synthetic-base")] +fn pr_test_base_input( + directory: Option, + number: Option, +) -> Result> { + match (directory, number) { + (None, None) => Ok(None), + (Some(directory), Some(number)) => { + let number = number + .to_str() + .context("PR synthetic base number is not UTF-8")? + .parse::() + .context("PR synthetic base number must be a positive integer")?; + Ok(Some( + mvm_build::pr_test_synthetic_base::PrTestSyntheticBase::new( + Path::new(&directory), + number, + )?, + )) + } + _ => anyhow::bail!("PR synthetic base requires both directory and pull request"), + } +} + +#[cfg(all(feature = "pr-test-synthetic-base", feature = "pr-test-guest-runtime"))] +fn pr_test_runtime_for_fixture<'a>( + sdk: &'a SdkTransportArgs, + fixture: &mvm_build::pr_test_synthetic_base::PrTestSyntheticBase, +) -> Result> { + match ( + sdk.compose_pr_test_runtime_dir.as_deref(), + sdk.compose_pr_test_pull_request, + ) { + (Some(directory), Some(pull_request)) if pull_request == fixture.pull_request() => { + Ok(composition::PrTestInput { + directory, + pull_request, + }) + } + _ => anyhow::bail!( + "PR synthetic base requires a PR test runtime quartet signed for the same pull request" + ), + } +} + +#[cfg(all(test, feature = "pr-test-synthetic-base"))] +mod tests { + use super::pr_test_base_input; + + #[test] + fn pr_test_base_input_is_opt_in_and_refuses_partial_selection() { + assert!(pr_test_base_input(None, None).unwrap().is_none()); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().as_os_str().to_owned(); + assert!(pr_test_base_input(Some(path.clone()), None).is_err()); + assert!(pr_test_base_input(None, Some("42".into())).is_err()); + assert!(pr_test_base_input(Some(path.clone()), Some("0".into())).is_err()); + assert!(pr_test_base_input(Some(path.clone()), Some("invalid".into())).is_err()); + assert_eq!( + pr_test_base_input(Some(path), Some("42".into())) + .unwrap() + .unwrap() + .pull_request(), + 42 + ); + } + + #[cfg(feature = "pr-test-guest-runtime")] + #[test] + fn synthetic_base_and_pr_runtime_must_name_the_same_signed_pull_request() { + use super::{SdkTransportArgs, pr_test_runtime_for_fixture}; + + let directory = tempfile::tempdir().unwrap(); + let fixture = + mvm_build::pr_test_synthetic_base::PrTestSyntheticBase::new(directory.path(), 42) + .unwrap(); + let mut sdk = SdkTransportArgs::default(); + assert!(pr_test_runtime_for_fixture(&sdk, &fixture).is_err()); + sdk.compose_pr_test_runtime_dir = Some(directory.path().to_path_buf()); + sdk.compose_pr_test_pull_request = Some(43); + assert!(pr_test_runtime_for_fixture(&sdk, &fixture).is_err()); + sdk.compose_pr_test_pull_request = Some(42); + assert_eq!( + pr_test_runtime_for_fixture(&sdk, &fixture) + .unwrap() + .pull_request, + 42 + ); + } +} diff --git a/crates/mvm-cli/src/lib.rs b/crates/mvm-cli/src/lib.rs index f8fd4ffcf4..ddd0ae9157 100644 --- a/crates/mvm-cli/src/lib.rs +++ b/crates/mvm-cli/src/lib.rs @@ -66,3 +66,5 @@ pub mod boot_policy { pub mod plan_builder { pub use mvm_core::plan::{SynthesisInput, synthesize_plan}; } +#[cfg(all(feature = "pr-test-synthetic-base", feature = "release-channel"))] +compile_error!("pr-test-synthetic-base is unavailable with release-channel"); diff --git a/crates/mvm-cli/src/pack_registry.rs b/crates/mvm-cli/src/pack_registry.rs index 3e7c4db904..8956e4fcbd 100644 --- a/crates/mvm-cli/src/pack_registry.rs +++ b/crates/mvm-cli/src/pack_registry.rs @@ -19,14 +19,20 @@ use mvm_core::registry_pack::application_bundle::{ ApplicationInstallStore, InstalledRegistryApplication, install_application_bundle, verify_application_bundle, }; +use mvm_core::registry_pack::application_component::{ + ComponentArchive, VerifiedApplicationComponentInputs, verify_application_component_inputs, +}; +use mvm_core::registry_pack::component_layer::{ + VerifiedComponentLayerInputs, verify_component_layer_inputs, +}; use mvm_core::registry_pack::runtime_layer::{ VerifiedRuntimeLayerInputs, verify_runtime_layer_inputs, }; use mvm_core::registry_pack::{ AuthenticatedRegistryManifest, InstalledRegistryPack, PackAdoption, PackLockfile, PackPin, - PackReference, RegistryPackImage, RegistryPackVerification, VerifiedRegistryApplication, - VerifiedRegistryManifest, VerifiedRegistryPack, adopt_registry_manifest, - verify_registry_manifest, + PackReference, RegistryPackFile, RegistryPackImage, RegistryPackVerification, + VerifiedRegistryApplication, VerifiedRegistryManifest, VerifiedRegistryPack, + adopt_registry_manifest, verify_registry_manifest, }; use mvm_core::registry_pack_store::{ PackPolicyDocument, adopt_install_and_pin, check_registry_pack_revocations_if_configured, @@ -143,6 +149,108 @@ pub fn acquire_runtime_layer( Ok(held) } +/// Private, verified component inputs; neither variant installs or admits a layer. +#[derive(Debug)] +pub enum AcquiredComponentInputs { + Application(VerifiedApplicationComponentInputs), + Layer(VerifiedComponentLayerInputs), +} + +/// Acquire exactly the graph's signed component pin without consulting the catalog. +/// +/// # Errors +/// +/// Refuses drift, untrusted metadata, stale or revoked trust, unsupported +/// sources, and any archive or policy bytes differing from the signed descriptor. +pub fn acquire_component_inputs( + config: &PackRegistryConfig, + expected: &PackPin, +) -> Result { + let fetched = download_pack(config, expected.reference())?; + let policy = load_publisher_policy_or_official_default( + &mvm_core::config::registry_pack_publisher_policy_path(), + )? + .policy; + let lock = PackLockfile::new(vec![expected.clone()])?; + let verified = verify_registry_manifest(&RegistryPackVerification::new( + expected.reference(), + &fetched.manifest_bytes, + &fetched.signature_bundle, + &lock, + &policy, + ))?; + if verified.reference().namespace() == "mvm" + || verified.signer().identity == mvm_core::registry_pack::OFFICIAL_PACK_SIGNING_IDENTITY + { + crate::official_revocations::ensure_official_feed_fresh(); + } + let (source, files) = match &verified { + VerifiedRegistryManifest::ApplicationComponent(component) => { + component.check_current_revocations()?; + ( + &component.manifest().application_component.archive, + component.manifest().files.as_slice(), + ) + } + VerifiedRegistryManifest::ComponentLayer(component) => { + component.check_current_revocations()?; + ( + &component.manifest().component().archive, + component.manifest().files(), + ) + } + _ => bail!("pinned pack is not an application, runtime or tool component"), + }; + let archive = stage_component_inputs(config, &fetched, source, files)?; + match verified { + VerifiedRegistryManifest::ApplicationComponent(component) => { + Ok(AcquiredComponentInputs::Application( + verify_application_component_inputs(&component, &archive, fetched.staged_path())?, + )) + } + VerifiedRegistryManifest::ComponentLayer(component) => Ok(AcquiredComponentInputs::Layer( + verify_component_layer_inputs(&component, &archive, fetched.staged_path())?, + )), + _ => unreachable!("component kind was checked before staging"), + } +} + +fn stage_component_inputs( + config: &PackRegistryConfig, + fetched: &FetchedPack, + source: &ComponentArchive, + files: &[RegistryPackFile], +) -> Result { + if !supported_runtime_layer_source(&source.source) { + bail!("component source is not a supported pinned HTTPS release asset"); + } + stage_component_policies(config, fetched, files)?; + let archive = fetched.staged_path().join("component.tar"); + download_payload_file(&source.source, &archive, source.archive_size, true)?; + Ok(archive) +} + +fn stage_component_policies( + config: &PackRegistryConfig, + fetched: &FetchedPack, + files: &[RegistryPackFile], +) -> Result<()> { + for file in files { + let target = fetched.staged_path().join(&file.path); + std::fs::create_dir_all(target.parent().context("component policy has no parent")?)?; + let url = pack_url( + config, + &fetched.reference, + &format!("{FILES_DIR}/{}", file.path), + ); + download_payload_file(&url, &target, file.size, false)?; + if Sha256Hex::from_bytes(&std::fs::read(&target)?) != file.sha256 { + bail!("component policy bytes differ from signed descriptor"); + } + } + Ok(()) +} + fn supported_runtime_layer_source(source: &str) -> bool { let prefix = "/tinylabscom/mvm-packs/releases/download/"; mvm_http::Url::parse(source).is_ok_and(|url| { @@ -604,6 +712,12 @@ fn pull_one( // authenticated bytes and digest for that later transaction. return Ok((VerifiedPull::Application(Box::new(application)), Vec::new())); } + VerifiedRegistryManifest::ApplicationComponent(_) => { + bail!("portable application component acquisition requires verified archive custody"); + } + VerifiedRegistryManifest::ComponentLayer(_) => { + bail!("runtime/tool component acquisition requires verified archive custody"); + } VerifiedRegistryManifest::RuntimeLayer(_) => { bail!("runtime layer acquisition requires verified archive custody"); } @@ -1073,6 +1187,82 @@ mod tests { assert_eq!(std::fs::read_dir(fetched.staged_path()).unwrap().count(), 0); } + #[test] + fn component_policy_staging_uses_the_exact_version_and_private_directory() { + let registry = tempfile::tempdir().unwrap(); + let reference: PackReference = "runtime/python@1.0.0".parse().unwrap(); + let pack = registry + .path() + .join("packs/runtime/python/1.0.0/files/pack"); + std::fs::create_dir_all(&pack).unwrap(); + std::fs::write(pack.join("group.toml"), b"group = 1").unwrap(); + let config = PackRegistryConfig { + registry_url: format!("file://{}", registry.path().display()), + }; + let fetched = FetchedPack { + reference, + manifest_bytes: Vec::new(), + signature_bundle: Vec::new(), + staged: TempDir::new().unwrap(), + }; + let file = RegistryPackFile { + path: "pack/group.toml".into(), + sha256: Sha256Hex::from_bytes(b"group = 1"), + size: 9, + }; + stage_component_policies(&config, &fetched, std::slice::from_ref(&file)).unwrap(); + assert_eq!( + std::fs::read(fetched.staged_path().join(&file.path)).unwrap(), + b"group = 1" + ); + std::fs::write(pack.join("group.toml"), b"changed!!").unwrap(); + let second = FetchedPack { + reference: fetched.reference.clone(), + manifest_bytes: Vec::new(), + signature_bundle: Vec::new(), + staged: TempDir::new().unwrap(), + }; + let error = stage_component_policies(&config, &second, &[file]).unwrap_err(); + assert!(error.to_string().contains("differ"), "{error}"); + } + + #[test] + fn component_acquisition_refuses_a_drifted_graph_pin_before_payload() { + let manifest = br#"{"schema_version":4,"kind":"tool_component"}"#; + let (registry, config) = file_registry(manifest); + let reference: PackReference = "runtime/python@1.2.3".parse().unwrap(); + let pin = PackPin::new(reference, Sha256Hex::from_bytes(b"older signed manifest")).unwrap(); + let error = acquire_component_inputs(&config, &pin).unwrap_err(); + let text = format!("{error:#}"); + assert!(text.contains("digest") || text.contains("hash"), "{text}"); + assert!( + !registry + .path() + .join("packs/runtime/python/1.2.3/files") + .exists() + ); + } + + #[test] + fn component_source_is_refused_before_policy_or_archive_fetch() { + let config = PackRegistryConfig { + registry_url: "file:///nonexistent-registry".into(), + }; + let fetched = FetchedPack { + reference: "runtime/python@1.0.0".parse().unwrap(), + manifest_bytes: Vec::new(), + signature_bundle: Vec::new(), + staged: TempDir::new().unwrap(), + }; + let source = ComponentArchive { + source: "https://attacker.example.invalid/layer.tar".into(), + archive_sha256: Sha256Hex::from_bytes(b"archive"), + archive_size: 7, + }; + assert!(stage_component_inputs(&config, &fetched, &source, &[]).is_err()); + assert_eq!(std::fs::read_dir(fetched.staged_path()).unwrap().count(), 0); + } + #[test] fn built_image_verifier_refusal_precedes_payload_download() { use mvm_core::image_set::{ArtifactName, ReleaseTag, RepositorySlug}; diff --git a/crates/mvm-client/Cargo.toml b/crates/mvm-client/Cargo.toml index 364311c47c..e91304c34a 100644 --- a/crates/mvm-client/Cargo.toml +++ b/crates/mvm-client/Cargo.toml @@ -52,6 +52,11 @@ clap = ["dep:clap"] # checks signed artifacts and keyless instruction-file signatures. Explicit # no-default-features builds report signatures as uncheckable, never verified. manifest-verify = ["mvm-core/manifest-verify"] +pr-test-guest-runtime = [ + "mvm-runtime/pr-test-guest-runtime", + "mvm-build/pr-test-guest-runtime", + "mvm-hostd/pr-test-guest-runtime", +] # Derive schemas for authored policy and instruction trust policy, and build # their emitters. Off by default so no shipped closure carries schemars; # mvm-contract/schema covers policy endpoint routes and backend kinds. diff --git a/crates/mvm-client/src/builder_bundle.rs b/crates/mvm-client/src/builder_bundle.rs index 28a5872968..aabb556ca0 100644 --- a/crates/mvm-client/src/builder_bundle.rs +++ b/crates/mvm-client/src/builder_bundle.rs @@ -164,6 +164,7 @@ pub fn export_builder_result( boot_assets: None, cli_boot_assets: None, canonical_linux_proof: None, + local_development_base_proof: None, supplemental_artifacts: &[], local_composition: None, out: &request.bundle_out, diff --git a/crates/mvm-client/src/launch/bundle_runtime.rs b/crates/mvm-client/src/launch/bundle_runtime.rs index b17398fa49..fc2eb37914 100644 --- a/crates/mvm-client/src/launch/bundle_runtime.rs +++ b/crates/mvm-client/src/launch/bundle_runtime.rs @@ -59,14 +59,15 @@ fn attach_installed_bundle(config: &mut VmStartConfig, backend: &str) -> Result< let verified = if let Some(host) = mvm_core::plan::bundle::existing_local_build_key()? { let id = key_id_from_pubkey(&host); let trust = LocalBundleTrustStore::new(release, id.clone(), host); - let verified = verify_bundle_file(&archive, &trust)?; + let verified = + verify_bundle_file(&archive, &trust).context("verifying bundle boot assets")?; ensure!( bundle_origin(&verified.manifest, &id, trust.release()) != BundleOrigin::Untrusted, "bundle boot assets refuse an unlabelled or foreign host build" ); verified } else { - verify_bundle_file(&archive, &release)? + verify_bundle_file(&archive, &release).context("verifying bundle boot assets")? }; let canonical = verified.manifest.members.iter().any(|member| { matches!( @@ -116,6 +117,31 @@ fn attach_installed_bundle(config: &mut VmStartConfig, backend: &str) -> Result< "bundle boot-assets architecture differs from host" ); let kind = BackendKind::from_label(backend).context("unknown bundle boot backend")?; + if let Some(proof) = &boot.local_development_base { + ensure!( + (cfg!(unix) && kind == BackendKind::Hvf) + || (cfg!(target_os = "linux") && kind == BackendKind::Firecracker), + "local development kernel requires native descriptor custody on this backend" + ); + ensure!( + config.canonical_kernel.is_none(), + "local development base cannot borrow canonical kernel authority" + ); + mvm_core::plan::bundle::verify_local_development_source(proof) + .context("local base composition source changed before launch")?; + let kernel = verified + .manifest + .find_by_role(&ArtifactRole::Kernel) + .context("local base kernel missing")?; + ensure!( + config + .kernel_read_sha256 + .as_deref() + .is_none_or(|digest| digest == kernel.sha256), + "local base conflicts with another kernel descriptor pin" + ); + config.kernel_read_sha256 = Some(kernel.sha256.clone()); + } // A canonical bundle retains a kernel generation only where the // backend can prove that generation's exit; refuse the rest before // paying for image selection or runtime assembly, not after. @@ -131,7 +157,12 @@ fn attach_installed_bundle(config: &mut VmStartConfig, backend: &str) -> Result< &support, &mvm_build::stage0_kernel::current_image_set_protocol_support(), )?; - attach_cli_runtime(config, &installed, runtime)?; + attach_cli_runtime( + config, + &installed, + runtime, + boot.local_development_base.is_some(), + )?; if needs_owner { let owner = std::sync::Arc::new(super::canonical_linux::prepare_canonical_linux( &archive, @@ -261,6 +292,7 @@ fn attach_cli_runtime( config: &mut VmStartConfig, installed: &mvm_core::plan::bundle::InstalledBundle, runtime: &mvm_core::plan::bundle::EmbeddedCliBootAssets, + local_development_base: bool, ) -> Result<()> { use mvm_build::guest_runtime::evidence::{ RuntimeArtifactSelection, verify_runtime_release_proof, @@ -290,7 +322,49 @@ fn attach_cli_runtime( version: runtime.version.clone(), arch: GuestArch::host(), }; + #[cfg(feature = "pr-test-guest-runtime")] + let pr_test = if let Some(pull_request) = runtime.pr_test_pull_request { + ensure!( + pull_request > 0 + && local_development_base + && installed.manifest.schema_version + >= mvm_contract::plan::bundle::PR_TEST_RUNTIME_MIN_SCHEMA + && installed.manifest.profile.as_deref() == Some("dev") + && installed + .manifest + .labels + .get(mvm_core::plan::bundle::BUNDLE_ORIGIN_LABEL) + .is_some_and(|origin| origin == mvm_core::plan::bundle::LOCAL_BUILD_ORIGIN), + "PR test runtime requires a host-signed local development bundle" + ); + Some(mvm_core::protocol::runtime_release::PrTestRuntimeProof { + input: proof.clone(), + pull_request, + }) + } else { + None + }; + #[cfg(not(feature = "pr-test-guest-runtime"))] + ensure!( + runtime.pr_test_pull_request.is_none(), + "PR test runtime requires an explicitly compiled test-only binary" + ); + #[cfg(not(feature = "pr-test-guest-runtime"))] + let _ = local_development_base; + #[cfg(feature = "pr-test-guest-runtime")] + ensure!( + config + .pr_test_runtime_proof + .as_ref() + .is_none_or(|existing| Some(existing) == pr_test.as_ref()), + "PR test runtime selection conflicts with the signed bundle" + ); if let Some(existing) = &config.runtime_release_proof { + #[cfg(feature = "pr-test-guest-runtime")] + ensure!( + pr_test.is_none(), + "PR test runtime cannot impersonate a release selection" + ); ensure!( existing == &proof, "CLI portable runtime proof conflicts with the signed bundle" @@ -310,6 +384,17 @@ fn attach_cli_runtime( let overlay_path = artifact(&runtime.overlay_artifact)?; let overlay_verity_path = artifact(&runtime.overlay_verity_artifact)?; let initramfs_path = artifact(&runtime.initramfs_artifact)?; + #[cfg(feature = "pr-test-guest-runtime")] + let verified = match &pr_test { + Some(pr_test) => mvm_build::guest_runtime::evidence::verify_pr_test_runtime_proof( + pr_test, + &runtime.version, + proof.arch, + ), + None => verify_runtime_release_proof(&proof, &runtime.version, proof.arch), + } + .context("verifying retained portable CLI runtime proof")?; + #[cfg(not(feature = "pr-test-guest-runtime"))] let verified = verify_runtime_release_proof(&proof, &runtime.version, proof.arch) .context("verifying retained portable CLI runtime release proof")?; let declaration = verified.declaration(proof.arch)?; @@ -328,8 +413,15 @@ fn attach_cli_runtime( minimum_protocol: mvm_runtime::runtime_evidence::MIN_GUEST_RPC_PROTOCOL_VERSION, host_protocol: mvm_runtime::runtime_evidence::GUEST_RPC_PROTOCOL_VERSION, })?; + #[cfg(feature = "pr-test-guest-runtime")] + let provenance = match pr_test { + Some(proof) => RuntimeBindingProvenance::PrTest { proof }, + None => RuntimeBindingProvenance::Release { proof }, + }; + #[cfg(not(feature = "pr-test-guest-runtime"))] + let provenance = RuntimeBindingProvenance::Release { proof }; RuntimeLaunchBinding::CliAssembly(CliRuntimeLaunchBinding { - provenance: RuntimeBindingProvenance::Release { proof }, + provenance, overlay_path, overlay_verity_path, initramfs_path, @@ -498,6 +590,7 @@ mod tests { overlay_roothash: "a".repeat(64), protocol_version: 2, min_supported_protocol_version: 2, + pr_test_pull_request: None, }; let artifacts = [ "runtime.tar.gz", @@ -545,13 +638,29 @@ mod tests { ..Default::default() }; let before = format!("{config:?}"); - let error = attach_cli_runtime(&mut config, &installed, &runtime).unwrap_err(); + let error = attach_cli_runtime(&mut config, &installed, &runtime, false).unwrap_err(); assert!( - format!("{error:#}").contains("verifying retained portable CLI runtime release proof") + format!("{error:#}").contains(if cfg!(feature = "pr-test-guest-runtime") { + "verifying retained portable CLI runtime proof" + } else { + "verifying retained portable CLI runtime release proof" + }) + ); + assert_eq!(format!("{config:?}"), before); + let mut pr_runtime = runtime.clone(); + pr_runtime.pr_test_pull_request = Some(42); + let error = attach_cli_runtime(&mut config, &installed, &pr_runtime, false).unwrap_err(); + assert!( + format!("{error:#}").contains(if cfg!(feature = "pr-test-guest-runtime") { + "host-signed local development bundle" + } else { + "explicitly compiled test-only binary" + }), + "test provenance cannot be used as a release: {error:#}" ); assert_eq!(format!("{config:?}"), before); std::fs::write(home.path().join("overlay.ext4"), b"changed").unwrap(); - let error = attach_cli_runtime(&mut config, &installed, &runtime).unwrap_err(); + let error = attach_cli_runtime(&mut config, &installed, &runtime, false).unwrap_err(); assert!(format!("{error:#}").contains("digest") || format!("{error:#}").contains("size")); assert_eq!(format!("{config:?}"), before); } @@ -1078,7 +1187,9 @@ mod tests { None, ) .unwrap_err(); - assert!(format!("{error:#}").contains("verifying bundle boot assets")); + let error = format!("{error:#}"); + assert!(error.contains("verifying bundle boot assets"), "{error}"); + assert!(error.contains("opening bundle archive"), "{error}"); assert!(config.runtime_overlay_path.is_none()); assert!(config.initrd_path.is_none()); assert!(!home.path().join("cache").exists()); @@ -1148,6 +1259,7 @@ mod tests { overlay_roothash: "a".repeat(64), protocol_version: 2, min_supported_protocol_version: 2, + pr_test_pull_request: None, }; let mut payload: Vec<(String, Vec)> = vec![ ("kernel".into(), kernel), diff --git a/crates/mvm-client/src/launch/runtime_source.rs b/crates/mvm-client/src/launch/runtime_source.rs index b9d7195656..1c9423efe6 100644 --- a/crates/mvm-client/src/launch/runtime_source.rs +++ b/crates/mvm-client/src/launch/runtime_source.rs @@ -165,6 +165,16 @@ fn attach_runtime_overlay_selection( if start_config.legacy_portable_runtime.is_some() || start_config.legacy_bundle_runtime.is_some() || start_config.runtime_release_proof.is_some() + || { + #[cfg(feature = "pr-test-guest-runtime")] + { + start_config.pr_test_runtime_proof.is_some() + } + #[cfg(not(feature = "pr-test-guest-runtime"))] + { + false + } + } || start_config.source_runtime_archive_sha256.is_some() || start_config.unsigned_runtime_development { diff --git a/crates/mvm-client/src/policy_profiles/mod.rs b/crates/mvm-client/src/policy_profiles/mod.rs index 37a20b37b1..8cffa4fdb3 100644 --- a/crates/mvm-client/src/policy_profiles/mod.rs +++ b/crates/mvm-client/src/policy_profiles/mod.rs @@ -31,7 +31,10 @@ pub mod source; pub use manifest::{FoldedLaunch, LaunchFlags, ResolvedManifest, fold}; pub use merge::{LayerSummary, ResolvedPolicy}; pub use model::{GroupFile, PolicyBody, ProfileFile}; -pub use resolve::{MAX_EXTENDS_DEPTH, Platform, PolicySelection, ProjectPolicy, resolve}; +pub use resolve::{ + MAX_EXTENDS_DEPTH, Platform, PolicySelection, ProjectPolicy, VerifiedPolicyInputs, resolve, + resolve_verified_policies, resolve_verified_policy, +}; pub use source::{LayerOrigin, PolicyError, PolicyRef, PolicyStore}; #[cfg(test)] diff --git a/crates/mvm-client/src/policy_profiles/resolve.rs b/crates/mvm-client/src/policy_profiles/resolve.rs index 5031549e04..ca937a5800 100644 --- a/crates/mvm-client/src/policy_profiles/resolve.rs +++ b/crates/mvm-client/src/policy_profiles/resolve.rs @@ -14,6 +14,9 @@ use std::path::{Path, PathBuf}; use mvm_contract::protocol::vm_backend::BackendKind; +use mvm_core::registry_pack::AuthenticatedRegistryManifest; +use mvm_core::registry_pack::application_component::VerifiedApplicationComponentInputs; +use mvm_core::registry_pack::component_layer::VerifiedComponentLayerInputs; use super::merge::{Layer, ResolvedPolicy, merge_layers}; use super::model::{GroupFile, HostArch, HostOs, PolicyBody, ProfileFile, WhenBlock}; @@ -224,6 +227,193 @@ pub fn resolve( Ok(resolved) } +/// A held, publisher-authenticated pack snapshot; raw policy bytes are not accepted here. +#[derive(Debug, Clone, Copy)] +pub enum VerifiedPolicyInputs<'a> { + /// Schema 3 application component. + Application(&'a VerifiedApplicationComponentInputs), + /// Schema 4 runtime or tool component. + Component(&'a VerifiedComponentLayerInputs), +} + +/// Interpret one signed pack's policy *request*, without granting it host authority. +/// +/// Only self-contained documents are supported: every external profile or group +/// reference is refused, even when a conditional block would not match. A caller +/// must separately enforce host authorization before admitting any requested grant. +/// +/// # Errors +/// +/// Refuses stale or changed verified inputs, malformed documents, references, +/// and policy requests rejected by [`merge_layers`]. +pub fn resolve_verified_policy( + inputs: VerifiedPolicyInputs<'_>, + platform: Platform, +) -> Result { + resolve_verified_policies(&[inputs], platform) +} + +/// Merge an ordered graph of signed pack policy requests in one pass. +/// +/// Inputs must be supplied in graph order. This is not a host grant: callers +/// must separately enforce host authorization for the resulting requests. +/// +/// # Errors +/// +/// Refuses any stale or changed input, unresolved reference, malformed document, +/// or policy conflict across packs. +pub fn resolve_verified_policies( + inputs: &[VerifiedPolicyInputs<'_>], + platform: Platform, +) -> Result { + let mut layers = Vec::new(); + let mut backend_conditioned = false; + for input in inputs { + let (reference, pin, group, profile) = match input { + VerifiedPolicyInputs::Application(inputs) => { + inputs + .reverify() + .map_err(|error| PolicyError::new("pack", error.to_string()))?; + let descriptor = inputs.descriptor(); + ( + descriptor.reference().to_string(), + descriptor.manifest_sha256().as_str().to_string(), + inputs.group(), + inputs.profile(), + ) + } + VerifiedPolicyInputs::Component(inputs) => { + inputs + .reverify() + .map_err(|error| PolicyError::new("pack", error.to_string()))?; + let descriptor = inputs.descriptor(); + ( + descriptor.reference().to_string(), + descriptor.manifest_sha256().as_str().to_string(), + inputs.group(), + inputs.profile(), + ) + } + }; + append_signed_documents( + &reference, + &pin, + group, + profile, + platform, + &mut layers, + &mut backend_conditioned, + )?; + } + let mut resolved = merge_layers(&layers)?; + resolved.backend_conditioned = backend_conditioned; + Ok(resolved) +} + +fn append_signed_documents( + reference: &str, + pin: &str, + group: Option<&[u8]>, + profile: Option<&[u8]>, + platform: Platform, + layers: &mut Vec, + backend_conditioned: &mut bool, +) -> Result<(), PolicyError> { + let label = format!("pack `{reference}` manifest sha256:{pin}"); + let group: Option = group + .map(|bytes| parse_signed_document(bytes, &format!("{label} group"))) + .transpose()?; + let profile: Option = profile + .map(|bytes| parse_signed_document(bytes, &format!("{label} profile"))) + .transpose()?; + if let Some(group) = group { + layers.push(Layer { + label: format!("{label} group"), + file: None, + origin: LayerOrigin::Pack, + body: group.body(), + }); + } + if let Some(profile) = profile { + if !profile.extends.is_empty() { + return Err( + PolicyError::new(&label, "signed profile dependencies are not resolved") + .at_key("extends"), + ); + } + if !profile.groups.include.is_empty() || !profile.groups.exclude.is_empty() { + return Err( + PolicyError::new(&label, "signed group references are not resolved") + .at_key("groups"), + ); + } + for (index, when) in profile.when.iter().enumerate() { + if !when.include.is_empty() || !when.exclude.is_empty() { + return Err(PolicyError::new( + &label, + "signed conditional group references are not resolved", + ) + .at_key(format!("when[{index}]"))); + } + if !when.backend.is_empty() { + *backend_conditioned = true; + } + if platform.matches(when) && !when.overrides.is_empty() { + layers.push(Layer { + label: format!("{label} when[{index}]"), + file: None, + origin: LayerOrigin::Pack, + body: when.overrides.clone(), + }); + } + } + if !profile.overrides.is_empty() { + layers.push(Layer { + label: format!("{label} overrides"), + file: None, + origin: LayerOrigin::Pack, + body: profile.overrides, + }); + } + } + Ok(()) +} + +#[cfg(test)] +fn resolve_signed_documents( + reference: &str, + pin: &str, + group: Option<&[u8]>, + profile: Option<&[u8]>, + platform: Platform, +) -> Result { + let mut layers = Vec::new(); + let mut backend_conditioned = false; + append_signed_documents( + reference, + pin, + group, + profile, + platform, + &mut layers, + &mut backend_conditioned, + )?; + let mut resolved = merge_layers(&layers)?; + resolved.backend_conditioned = backend_conditioned; + Ok(resolved) +} + +fn parse_signed_document( + bytes: &[u8], + label: &str, +) -> Result { + let text = std::str::from_utf8(bytes) + .map_err(|error| PolicyError::new(label, format!("invalid UTF-8: {error}")))?; + DocFormat::Toml + .parse(text) + .map_err(|error| PolicyError::new(label, error)) +} + /// Resolve a single file that is a profile or, failing that, a group, as a /// user-authored layer. What `mvmctl policy validate PATH` checks. /// diff --git a/crates/mvm-client/src/policy_profiles/resolve_tests.rs b/crates/mvm-client/src/policy_profiles/resolve_tests.rs index 45627e2367..de84072060 100644 --- a/crates/mvm-client/src/policy_profiles/resolve_tests.rs +++ b/crates/mvm-client/src/policy_profiles/resolve_tests.rs @@ -52,6 +52,234 @@ fn cli(raw: &[&str]) -> Vec { raw.iter().map(ToString::to_string).collect() } +#[test] +fn signed_snapshot_preserves_pin_origin_and_merges_denies_and_minima() { + let resolved = resolve_signed_documents( + "runtime/python@3.0.0", + "abc123", + Some(b"[network]\nallow = [\"blocked.test\", \"open.test\"]\n[resources]\nmax_cpus = 4\n"), + Some(b"[overrides.network]\ndeny = [\"blocked.test\"]\n[overrides.resources]\nmax_cpus = 2\n"), + Platform::default(), + ) + .unwrap(); + assert_eq!(resolved.policy.network.allow, ["open.test:443"]); + assert_eq!(resolved.policy.resources.max_cpus, Some(2)); + assert!(resolved.layers.iter().all(|layer| { + layer.origin == LayerOrigin::Pack + && layer.label.contains("runtime/python@3.0.0") + && layer.label.contains("sha256:abc123") + && layer.file.is_none() + })); +} + +#[test] +fn signed_snapshot_is_never_a_user_escape_hatch_or_share_grant() { + for text in [ + "[env]\nreadmit = [\"PATH\"]\n", + "[[shares.mount]]\nhost = \"/host\"\nguest = \"/guest\"\n", + ] { + let resolved = resolve_signed_documents( + "tool/git@1.0.0", + "pin", + Some(text.as_bytes()), + None, + Platform::default(), + ) + .unwrap(); + assert!(resolved.policy.env.readmit.is_empty()); + assert!(resolved.policy.shares.mount.is_empty()); + assert!(resolved.notes.iter().any(|note| note.contains("ignored"))); + } +} + +#[test] +fn signed_snapshot_refuses_every_unresolved_reference_even_in_inactive_conditions() { + for (text, key) in [ + ("extends = \"default\"\n", "extends"), + ("[groups]\ninclude = [\"offline\"]\n", "groups"), + ("[groups]\nexclude = [\"offline\"]\n", "groups"), + ( + "[[when]]\nos = \"linux\"\ninclude = [\"offline\"]\n", + "when[0]", + ), + ( + "[[when]]\nos = \"linux\"\nexclude = [\"offline\"]\n", + "when[0]", + ), + ] { + let err = resolve_signed_documents( + "runtime/python@3.0.0", + "pin", + None, + Some(text.as_bytes()), + Platform::default(), + ) + .unwrap_err(); + assert_eq!(err.key.as_deref(), Some(key), "{text}: {err}"); + } +} + +#[test] +fn signed_snapshot_refuses_invalid_bytes_and_merges_block() { + assert!( + resolve_signed_documents( + "app/a@1.0.0", + "pin", + Some(&[0xff]), + None, + Platform::default() + ) + .is_err() + ); + assert!( + resolve_signed_documents( + "app/a@1.0.0", + "pin", + Some(b"unknown = true\n"), + None, + Platform::default() + ) + .is_err() + ); + let blocked = resolve_signed_documents( + "app/a@1.0.0", + "pin", + Some(b"[network]\nblock = true\n"), + Some(b"[overrides.network]\nallow = [\"conditional.test\"]\n"), + Platform::default(), + ) + .unwrap(); + assert_eq!(blocked.policy.network.block, Some(true)); + assert!(blocked.policy.network.allow.is_empty()); + assert!( + resolve_signed_documents( + "app/a@1.0.0", + "pin", + Some(b"[network]\nblock = true\n"), + Some(b"[overrides.network]\nblock = false\n"), + Platform::default(), + ) + .is_err() + ); +} + +#[test] +fn signed_snapshot_applies_only_matching_conditional_overrides() { + let profile = b"[[when]]\nos = \"linux\"\n[when.overrides.network]\ndeny = [\"linux.test\"]\n\ + [[when]]\nos = \"macos\"\n[when.overrides.network]\ndeny = [\"mac.test\"]\n"; + let resolved = resolve_signed_documents( + "runtime/python@3.0.0", + "pin", + Some(b"[network]\nallow = [\"linux.test\", \"mac.test\"]\n"), + Some(profile), + Platform { + os: Some(super::super::model::HostOs::Linux), + ..Platform::default() + }, + ) + .unwrap(); + assert_eq!(resolved.policy.network.allow, ["mac.test:443"]); + assert_eq!(resolved.layers.len(), 2); +} + +#[test] +fn ordered_signed_packs_merge_denies_and_minima_across_component_boundaries() { + let mut layers = Vec::new(); + let mut backend_conditioned = false; + for (reference, pin, group) in [ + ( + "runtime/base@1.0.0", + "first", + "[network]\nallow = [\"blocked.test\", \"open.test\"]\n[resources]\nmax_cpus = 4\n", + ), + ( + "tool/guard@1.0.0", + "second", + "[network]\ndeny = [\"blocked.test\"]\n[resources]\nmax_cpus = 2\n", + ), + ( + "app/workload@1.0.0", + "third", + "[network]\nallow = [\"blocked.test\", \"later.test\"]\n[resources]\nmax_cpus = 3\n", + ), + ] { + append_signed_documents( + reference, + pin, + Some(group.as_bytes()), + None, + Platform::default(), + &mut layers, + &mut backend_conditioned, + ) + .unwrap(); + } + let resolved = merge_layers(&layers).unwrap(); + assert_eq!( + resolved.policy.network.allow, + ["open.test:443", "later.test:443"] + ); + assert_eq!(resolved.policy.resources.max_cpus, Some(2)); + assert_eq!(resolved.layers.len(), 3); + for (layer, pin) in resolved.layers.iter().zip(["first", "second", "third"]) { + assert_eq!(layer.origin, LayerOrigin::Pack); + assert!(layer.label.contains(&format!("sha256:{pin}"))); + } +} + +#[test] +fn signed_pack_cannot_unblock_another_or_resolve_a_later_dependency() { + let mut layers = Vec::new(); + let mut backend_conditioned = false; + append_signed_documents( + "runtime/base@1.0.0", + "first", + Some(b"[network]\nblock = true\n"), + None, + Platform::default(), + &mut layers, + &mut backend_conditioned, + ) + .unwrap(); + append_signed_documents( + "tool/guard@1.0.0", + "second", + Some(b"[network]\nallow = [\"outside.test\"]\n"), + None, + Platform::default(), + &mut layers, + &mut backend_conditioned, + ) + .unwrap(); + let blocked = merge_layers(&layers).unwrap(); + assert_eq!(blocked.policy.network.block, Some(true)); + assert!(blocked.policy.network.allow.is_empty()); + + let mut conflicting = layers.clone(); + append_signed_documents( + "app/workload@1.0.0", + "third", + None, + Some(b"[overrides.network]\nblock = false\n"), + Platform::default(), + &mut conflicting, + &mut backend_conditioned, + ) + .unwrap(); + assert!(merge_layers(&conflicting).is_err()); + let err = append_signed_documents( + "app/workload@1.0.0", + "third", + None, + Some(b"extends = \"runtime/base@1.0.0\"\n"), + Platform::default(), + &mut layers, + &mut backend_conditioned, + ) + .unwrap_err(); + assert_eq!(err.key.as_deref(), Some("extends")); +} + // ---- built-ins ------------------------------------------------------------ #[test] diff --git a/crates/mvm-client/src/portable_boot_assets.rs b/crates/mvm-client/src/portable_boot_assets.rs index f9176af467..4e4b2e1a82 100644 --- a/crates/mvm-client/src/portable_boot_assets.rs +++ b/crates/mvm-client/src/portable_boot_assets.rs @@ -29,10 +29,12 @@ pub struct PreparedPortableBootAssets { pub(crate) initrd: PathBuf, pub(crate) binaries: MvmRuntimeBinaries, pub(crate) release_proof: RuntimeReleaseProof, + #[cfg(feature = "pr-test-guest-runtime")] + pub(crate) pr_test_proof: Option, pub(crate) checksum_signature_bundle: PathBuf, pub(crate) runtime: EmbeddedCliBootAssets, _prepared: VerifiedRuntimeArtifacts, - publisher: RetainedPublisherProof, + publisher: Option, home: PathBuf, bindings: Vec<(PathBuf, String, u64)>, } @@ -40,6 +42,45 @@ pub struct PreparedPortableBootAssets { impl PreparedPortableBootAssets { pub fn acquire(arch: GuestArch) -> Result { let image_set = PublishedImageSet::acquire_workload()?; + Self::acquire_with_set(Some(image_set), arch) + } + + /// Acquire only the signed CLI guest runtime. An explicitly selected + /// local-development Linux source supplies its own kernel, not this + /// build's signed default image set. + pub fn acquire_local(arch: GuestArch) -> Result { + Self::acquire_with_set(None, arch) + } + + /// Explicit PR-only input: genuine keyless-signed archive and checksum, + /// never a substitute for a published CLI release or a production run. + #[cfg(feature = "pr-test-guest-runtime")] + pub fn acquire_pr_test_local( + arch: GuestArch, + directory: &Path, + pull_request: u64, + ) -> Result { + anyhow::ensure!( + mvm_build::artifact_acquisition::compiled_channel().permits_automatic_builds(), + "PR test runtime cannot run in a release-channel binary" + ); + let asset = mvm_build::guest_bins::guest_bins_archive_name(env!("CARGO_PKG_VERSION")); + let archive = directory.join(&asset); + let proof = mvm_core::protocol::runtime_release::PrTestRuntimeProof { + input: RuntimeReleaseProof { + archive: archive.clone(), + checksum: directory.join(format!("{asset}.sha256")), + signature_bundle: directory.join(format!("{asset}.bundle")), + archive_sha256: sha256_file(&archive)?, + version: env!("CARGO_PKG_VERSION").to_string(), + arch, + }, + pull_request, + }; + Self::from_source_with_pr_test(None, arch, &proof.input, Some(&proof)) + } + + fn acquire_with_set(image_set: Option, arch: GuestArch) -> Result { let cache = PathBuf::from(mvm_core::config::mvm_cache_dir()); let runtime = mvm_build::guest_runtime::resolve_or_download_guest_runtime_quartet( &cache, @@ -49,36 +90,84 @@ impl PreparedPortableBootAssets { let proof = runtime .release_proof .context("portable creation requires a signed CLI runtime release")?; - Self::from_set(image_set, arch, &proof) + Self::from_source(image_set, arch, &proof) } + #[cfg(test)] fn from_set( image_set: PublishedImageSet, arch: GuestArch, source_proof: &RuntimeReleaseProof, + ) -> Result { + Self::from_source(Some(image_set), arch, source_proof) + } + + fn from_source( + image_set: Option, + arch: GuestArch, + source_proof: &RuntimeReleaseProof, + ) -> Result { + Self::from_source_with_pr_test(image_set, arch, source_proof, None) + } + + fn from_source_with_pr_test( + image_set: Option, + arch: GuestArch, + source_proof: &RuntimeReleaseProof, + #[cfg(feature = "pr-test-guest-runtime")] pr_test: Option< + &mvm_core::protocol::runtime_release::PrTestRuntimeProof, + >, + #[cfg(not(feature = "pr-test-guest-runtime"))] _pr_test: Option<&()>, ) -> Result { anyhow::ensure!( source_proof.version == env!("CARGO_PKG_VERSION") && source_proof.arch == arch, "portable runtime proof differs from the selected CLI version or architecture" ); - verify_runtime_release_proof(source_proof, &source_proof.version, arch).with_context( - || { - format!( - "authenticate selected runtime archive {}", - source_proof.archive.display() + #[cfg(feature = "pr-test-guest-runtime")] + let checked = match pr_test { + Some(pr_test) => { + anyhow::ensure!( + &pr_test.input == source_proof && image_set.is_none(), + "PR test input is not a local runtime selection" + ); + mvm_build::guest_runtime::evidence::verify_pr_test_runtime_proof( + pr_test, + &source_proof.version, + arch, ) - }, - )?; - let publisher = image_set.retained_publisher_proof()?; + } + None => verify_runtime_release_proof(source_proof, &source_proof.version, arch), + }; + #[cfg(not(feature = "pr-test-guest-runtime"))] + let checked = verify_runtime_release_proof(source_proof, &source_proof.version, arch); + checked.with_context(|| { + format!( + "authenticate selected runtime archive {}", + source_proof.archive.display() + ) + })?; + let publisher = image_set + .as_ref() + .map(PublishedImageSet::retained_publisher_proof) + .transpose()?; let home = mvm_core::config::mvm_home_strict()?; let directory = tempfile::tempdir().context("stage portable release boot assets")?; mvm_core::config::create_private_dir(directory.path())?; let root = directory.path(); - let kernel_member = - mvm_core::plan::bundle::select_cli_boot_kernel(image_set.manifest(), arch)?; - let kernel_artifact = single_artifact(kernel_member)?; let kernel = root.join("vmlinux"); - image_set.fetch_artifact(kernel_artifact, &kernel)?; + let kernel_binding = if let Some(image_set) = &image_set { + let kernel_member = + mvm_core::plan::bundle::select_cli_boot_kernel(image_set.manifest(), arch)?; + let kernel_artifact = single_artifact(kernel_member)?; + image_set.fetch_artifact(kernel_artifact, &kernel)?; + Some(( + kernel.clone(), + kernel_artifact.sha256.as_str().to_owned(), + kernel_artifact.size, + )) + } else { + None + }; let archive = root.join("guest-runtime.tar.gz"); let checksum = root.join("guest-runtime.sha256"); let signature_bundle = root.join("guest-runtime.bundle"); @@ -101,8 +190,40 @@ impl PreparedPortableBootAssets { signature_bundle, ..source_proof.clone() }; + #[cfg(feature = "pr-test-guest-runtime")] + let pr_test_proof = + pr_test.map( + |proof| mvm_core::protocol::runtime_release::PrTestRuntimeProof { + input: release_proof.clone(), + pull_request: proof.pull_request, + }, + ); + #[cfg(feature = "pr-test-guest-runtime")] + let verified = match &pr_test_proof { + Some(proof) => mvm_build::guest_runtime::evidence::verify_pr_test_runtime_proof( + proof, + &source_proof.version, + arch, + )?, + None => verify_runtime_release_proof(&release_proof, &source_proof.version, arch)?, + }; + #[cfg(not(feature = "pr-test-guest-runtime"))] let verified = verify_runtime_release_proof(&release_proof, &source_proof.version, arch)?; let cache = root.join("runtime-cache"); + #[cfg(feature = "pr-test-guest-runtime")] + let guest_runtime = if let Some(proof) = &pr_test_proof { + mvm_build::guest_runtime::install_pr_test_guest_runtime_archive(&cache, proof)? + } else { + mvm_build::guest_runtime::install_signed_guest_runtime_archive( + &cache, + &release_proof.archive, + &release_proof.checksum, + &release_proof.signature_bundle, + &release_proof.version, + arch, + )? + }; + #[cfg(not(feature = "pr-test-guest-runtime"))] let guest_runtime = mvm_build::guest_runtime::install_signed_guest_runtime_archive( &cache, &release_proof.archive, @@ -150,6 +271,16 @@ impl PreparedPortableBootAssets { overlay_roothash: declaration.overlay_roothash.clone(), protocol_version: declaration.protocol_version, min_supported_protocol_version: declaration.min_supported_protocol_version, + pr_test_pull_request: { + #[cfg(feature = "pr-test-guest-runtime")] + { + pr_test_proof.as_ref().map(|proof| proof.pull_request) + } + #[cfg(not(feature = "pr-test-guest-runtime"))] + { + None + } + }, }; let bin = guest_runtime.root.join(arch.to_string()).join("bin"); let binaries = MvmRuntimeBinaries { @@ -158,11 +289,7 @@ impl PreparedPortableBootAssets { egress_client: bin.join("mvm-egress-client"), entrypoint_runner: bin.join("mvm-oci-entrypoint"), }; - let mut bindings = vec![( - kernel.clone(), - kernel_artifact.sha256.as_str().to_owned(), - kernel_artifact.size, - )]; + let mut bindings = kernel_binding.into_iter().collect::>(); for path in [ &runtime_overlay, &runtime_overlay_verity, @@ -190,6 +317,8 @@ impl PreparedPortableBootAssets { initrd, binaries, release_proof, + #[cfg(feature = "pr-test-guest-runtime")] + pr_test_proof, checksum_signature_bundle, runtime, _prepared: prepared, @@ -204,20 +333,30 @@ impl PreparedPortableBootAssets { mvm_core::config::mvm_home_strict()? == self.home, "portable producer closure belongs to another home" ); - let current = mvm_core::image_set_revocation::ImageSetRevocationStore::in_mvm_home() - .load(chrono::Utc::now())?; - anyhow::ensure!( - current.checkpoint() == self.publisher.revocations().verified().checkpoint(), - "portable publisher policy advanced; capture a fresh closure" - ); - let request = mvm_core::image_set::ImageSetVerification::new( - self.publisher.root().manifest_bytes(), - self.publisher.root().signature_bundle(), - &mvm_core::image_set::image_train_lock().image_set, - Path::new(""), - ) - .with_revocations(¤t); - mvm_core::image_set::verify_image_set_root(&request)?; + #[cfg(feature = "pr-test-guest-runtime")] + if let Some(proof) = &self.pr_test_proof { + mvm_build::guest_runtime::evidence::verify_pr_test_runtime_proof( + proof, + env!("CARGO_PKG_VERSION"), + GuestArch::host(), + )?; + } + if let Some(publisher) = &self.publisher { + let current = mvm_core::image_set_revocation::ImageSetRevocationStore::in_mvm_home() + .load(chrono::Utc::now())?; + anyhow::ensure!( + current.checkpoint() == publisher.revocations().verified().checkpoint(), + "portable publisher policy advanced; capture a fresh closure" + ); + let request = mvm_core::image_set::ImageSetVerification::new( + publisher.root().manifest_bytes(), + publisher.root().signature_bundle(), + &mvm_core::image_set::image_train_lock().image_set, + Path::new(""), + ) + .with_revocations(¤t); + mvm_core::image_set::verify_image_set_root(&request)?; + } verify_bindings(&self.bindings) } @@ -232,7 +371,12 @@ impl PreparedPortableBootAssets { /// Original signed Linux root selected for the portable boot closure. pub fn image_set_manifest_sha256(&self) -> &mvm_core::packs::Sha256Hex { - self.publisher.root().root().manifest_sha256() + self.publisher + .as_ref() + .expect("signed boot closure has a Linux publisher") + .root() + .root() + .manifest_sha256() } /// Private producer input directories, for retention diagnostics only. @@ -243,6 +387,10 @@ impl PreparedPortableBootAssets { /// Check the built slot rather than silently replacing its selected kernel. pub fn verify_build_kernel(&self, kernel: &Path) -> Result<()> { + anyhow::ensure!( + self.publisher.is_some(), + "local runtime-only closure cannot attest a signed Linux kernel" + ); self.verify_integrity()?; verify_kernel_selection(kernel, &self.bindings[0].1)?; Ok(()) @@ -316,6 +464,58 @@ impl PreparedPortableBootAssets { signer: &dyn mvm_bundler::BundleSigner, ) -> Result { self.verify_build_kernel(Path::new(inputs.vmlinux))?; + let publisher = self + .publisher + .as_ref() + .context("signed boot closure has no Linux publisher")?; + self.stage_selected( + inputs, + signer, + publisher.root().manifest_bytes(), + publisher.root().root().manifest_sha256(), + None, + ) + } + + /// Seal an explicitly selected local-development kernel and base while + /// retaining the separately signed CLI guest runtime unchanged. + pub fn stage_local( + &self, + inputs: &mvm_bundler::BundleExportInputs<'_>, + signer: &dyn mvm_bundler::BundleSigner, + manifest_bytes: &[u8], + proof: mvm_core::plan::bundle::LocalDevelopmentBaseProof, + ) -> Result { + self.verify_integrity()?; + #[cfg(feature = "pr-test-guest-runtime")] + anyhow::ensure!( + self.pr_test_proof.is_none() || inputs.profile == Some("dev"), + "PR test runtime cannot be sealed with a production profile" + ); + anyhow::ensure!( + inputs.profile == Some("dev") + && inputs.local_build + && proof.manifest_artifact == self.runtime.manifest_artifact, + "local base requires a host-signed development bundle" + ); + let manifest_sha256 = mvm_core::packs::Sha256Hex::from_bytes(manifest_bytes); + self.stage_selected( + inputs, + signer, + manifest_bytes, + &manifest_sha256, + Some(proof), + ) + } + + fn stage_selected( + &self, + inputs: &mvm_bundler::BundleExportInputs<'_>, + signer: &dyn mvm_bundler::BundleSigner, + manifest_bytes: &[u8], + manifest_sha256: &mvm_core::packs::Sha256Hex, + local: Option, + ) -> Result { verify_export_selection(inputs, &self.runtime.arch, &sha256_file(&self.initrd)?)?; let files = [ ( @@ -349,15 +549,23 @@ impl PreparedPortableBootAssets { ]; let mut selected = inputs.clone(); selected.cli_boot_assets = Some(mvm_bundler::CliBootAssetsInputs { - manifest_bytes: self.publisher.root().manifest_bytes(), - manifest_sha256: self.publisher.root().root().manifest_sha256(), + manifest_bytes, + manifest_sha256, runtime: &self.runtime, artifacts: &files, }); - selected.canonical_linux_proof = Some(mvm_bundler::CanonicalLinuxProofInputs { - root: self.publisher.root(), - revocations: self.publisher.revocations(), - }); + if let Some(proof) = local { + selected.local_development_base_proof = Some(proof); + } else { + let publisher = self + .publisher + .as_ref() + .context("signed boot closure has no Linux publisher")?; + selected.canonical_linux_proof = Some(mvm_bundler::CanonicalLinuxProofInputs { + root: publisher.root(), + revocations: publisher.revocations(), + }); + } let output = mvm_bundler::stage_bundle_with_signer(&selected, signer)?; if let Err(error) = self.verify_integrity() { return Err(output.abort(error)); @@ -459,6 +667,62 @@ mod tests { use mvm_core::image_set::{ImageSetRole, MemberTarget, WorkloadImageProfile}; use mvm_core::util::test_env::TestEnv; + #[cfg(feature = "pr-test-guest-runtime")] + #[test] + #[ignore = "requires the PR workflow's real keyless-signed guest runtime quartet"] + fn pr_test_local_acquisition_checks_assembled_runtime_independently() { + let directory = PathBuf::from( + std::env::var_os("MVM_PR_TEST_RUNTIME_DIR").expect("PR runtime quartet directory"), + ); + let pull_request: u64 = std::env::var("MVM_PR_TEST_PULL_REQUEST") + .expect("PR number") + .parse() + .expect("numeric PR number"); + let home = tempfile::tempdir().unwrap(); + let mut env = TestEnv::new(); + env.isolate_mvm_home(home.path()); + let assets = PreparedPortableBootAssets::acquire_pr_test_local( + GuestArch::host(), + &directory, + pull_request, + ) + .expect("PR-signed runtime assembles from the exact archive"); + assert_eq!(assets.runtime.pr_test_pull_request, Some(pull_request)); + assets + .verify_integrity() + .expect("assembled runtime unchanged"); + let proof = assets + .pr_test_proof + .as_ref() + .expect("distinct PR provenance"); + let verified = mvm_build::guest_runtime::evidence::verify_pr_test_runtime_proof( + proof, + env!("CARGO_PKG_VERSION"), + GuestArch::host(), + ) + .unwrap(); + let mut selection = RuntimeArtifactSelection { + arch: GuestArch::host(), + overlay: &assets.runtime_overlay, + overlay_verity: &assets.runtime_overlay_verity, + overlay_roothash: &assets.runtime.overlay_roothash, + initramfs: &assets.initrd, + minimum_protocol: mvm_runtime::runtime_evidence::MIN_GUEST_RPC_PROTOCOL_VERSION, + host_protocol: mvm_runtime::runtime_evidence::GUEST_RPC_PROTOCOL_VERSION, + }; + verified.verify_selected_artifacts(&selection).unwrap(); + let wrong = tempfile::tempdir().unwrap(); + let altered_overlay = wrong.path().join("overlay"); + let altered_initrd = wrong.path().join("initrd"); + std::fs::write(&altered_overlay, b"altered overlay").unwrap(); + std::fs::write(&altered_initrd, b"altered initramfs").unwrap(); + selection.overlay = &altered_overlay; + assert!(verified.verify_selected_artifacts(&selection).is_err()); + selection.overlay = &assets.runtime_overlay; + selection.initramfs = &altered_initrd; + assert!(verified.verify_selected_artifacts(&selection).is_err()); + } + #[test] #[ignore = "requires genuine signed runtime, published Linux proofs, and an existing sealed workload"] fn signed_release_exports_real_canonical_workload() { diff --git a/crates/mvm-contract/src/lib.rs b/crates/mvm-contract/src/lib.rs index 1c0b1ad2b0..95fa6eed9f 100644 --- a/crates/mvm-contract/src/lib.rs +++ b/crates/mvm-contract/src/lib.rs @@ -62,6 +62,9 @@ pub mod pack_spec; /// Versioned composition authoring, separate from the existing Nix pack contract. #[cfg(feature = "protocol")] pub mod pack_spec_v2; +/// Ordered signed-pack graph and independent, explicit command selection. +#[cfg(feature = "protocol")] +pub mod pack_spec_v3; /// Names one workload uses to address another over the existing /// host-mediated egress path. #[cfg(feature = "protocol")] diff --git a/crates/mvm-contract/src/pack_spec_v3.rs b/crates/mvm-contract/src/pack_spec_v3.rs new file mode 100644 index 0000000000..f5a2fa1ab4 --- /dev/null +++ b/crates/mvm-contract/src/pack_spec_v3.rs @@ -0,0 +1,363 @@ +//! Ordered signed-pack references and separately selected launch commands. +//! +//! These are requests, not verified packs, a lock, a build result or permission +//! to launch. Consumers must verify each referenced manifest signature, digest, +//! publisher, revocation and compatibility before materializing the graph. +//! Neither a runtime nor a tool starts a service by being present in the graph. + +use alloc::{collections::BTreeSet, string::String, vec::Vec}; +use serde::{Deserialize, Serialize}; + +use crate::{ + pack_lock::lower_hex, + pack_spec::{PackIdentity, PackTarget, relative_path}, +}; + +/// New authored graph schema; existing v1 and v2 formats remain independent. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ComposedGraphSchema { + #[serde(rename = "mvm.pack-spec/v3")] + V3, +} + +/// A reference to the exact bytes of a signed registry manifest. +/// +/// This does not attest to a valid signature: the consumer must verify it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SignedPackPin { + pub name: String, + pub version: String, + pub manifest_sha256: String, +} + +impl SignedPackPin { + /// Refuse ambiguous coordinates and non-canonical SHA-256 digests. + /// + /// # Errors + /// + /// Returns an error for an invalid name, version or digest. + pub fn validate(&self) -> Result<(), ComposedGraphError> { + PackIdentity { + name: self.name.clone(), + version: self.version.clone(), + } + .validate() + .map_err(|_| ComposedGraphError::Pin)?; + if self.version.contains('+') || !lower_hex(&self.manifest_sha256, 64) { + return Err(ComposedGraphError::Pin); + } + Ok(()) + } +} + +/// Ordered input layers. Entrypoints come from verified application manifests, +/// never from authored graph data. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum GraphNode { + Application { pin: SignedPackPin }, + Runtime { pin: SignedPackPin }, + Tool { pin: SignedPackPin }, +} + +impl GraphNode { + /// The pinned registry manifest for this layer. + pub fn pin(&self) -> &SignedPackPin { + match self { + Self::Application { pin, .. } | Self::Runtime { pin } | Self::Tool { pin } => pin, + } + } +} + +/// An ordered graph of explicitly named, digest-pinned registry packs. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "ComposedGraphWire")] +pub struct ComposedGraph { + pub schema: ComposedGraphSchema, + pub graph: Vec, +} + +/// One authored invocation binds the ordered graph, target and command request. +/// +/// Base selection and host grants remain launch inputs, not pack declarations. +/// This document does not attest to publisher signatures or authorize a boot. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "ComposedRunWire")] +pub struct ComposedRunRequest { + pub schema: ComposedRunSchema, + pub target: PackTarget, + pub graph: ComposedGraph, + pub entrypoint: EntrypointRequest, +} + +/// The single-file invocation format for the v3 graph. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ComposedRunSchema { + #[serde(rename = "mvm.composed-run/v1")] + V1, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ComposedRunWire { + schema: ComposedRunSchema, + target: PackTarget, + graph: ComposedGraph, + entrypoint: EntrypointRequest, +} + +impl TryFrom for ComposedRunRequest { + type Error = ComposedGraphError; + + fn try_from(wire: ComposedRunWire) -> Result { + let request = Self { + schema: wire.schema, + target: wire.target, + graph: wire.graph, + entrypoint: wire.entrypoint, + }; + request.validate()?; + Ok(request) + } +} + +impl ComposedRunRequest { + /// Validate directly constructed invocations before acquiring any pack bytes. + /// + /// # Errors + /// Refuses invalid graph pins or command requests. + pub fn validate(&self) -> Result<(), ComposedGraphError> { + self.graph.validate()?; + self.entrypoint.validate() + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ComposedGraphWire { + schema: ComposedGraphSchema, + graph: Vec, +} + +impl TryFrom for ComposedGraph { + type Error = ComposedGraphError; + + fn try_from(wire: ComposedGraphWire) -> Result { + let graph = Self { + schema: wire.schema, + graph: wire.graph, + }; + graph.validate()?; + Ok(graph) + } +} + +/// The independent launch request schema; it cannot reinterpret a pack spec. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum EntrypointRequestSchema { + #[serde(rename = "mvm.entrypoint-request/v1")] + V1, +} + +/// Optional, explicit command sources; neither a runtime nor a tool auto-starts. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "EntrypointRequestWire")] +pub struct EntrypointRequest { + pub schema: EntrypointRequestSchema, + /// Highest precedence, supplied by the runtime invocation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runtime_command_override: Option>, + /// User workload/config command, used when runtime did not override it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workload_command: Option>, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct EntrypointRequestWire { + schema: EntrypointRequestSchema, + #[serde(default)] + runtime_command_override: Option>, + #[serde(default)] + workload_command: Option>, +} + +impl TryFrom for EntrypointRequest { + type Error = ComposedGraphError; + + fn try_from(wire: EntrypointRequestWire) -> Result { + let request = Self { + schema: wire.schema, + runtime_command_override: wire.runtime_command_override, + workload_command: wire.workload_command, + }; + request.validate()?; + Ok(request) + } +} + +/// Where the selected literal argv came from, without constructing a service. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SelectedEntrypoint<'a> { + RuntimeOverride(&'a [String]), + Workload(&'a [String]), + Application { + pin: &'a SignedPackPin, + argv: &'a [String], + }, +} + +/// Validation errors do not echo command arguments or registry coordinates. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum ComposedGraphError { + #[error("graph must contain between 1 and 128 layers including an application")] + Graph, + #[error("invalid or duplicate signed manifest pin")] + Pin, + #[error("pack kind conflicts with its registry namespace")] + Kind, + #[error("unsafe or empty launch command")] + Command, + #[error("could not serialize composed request")] + Serialization, +} + +impl ComposedGraph { + /// Validate directly constructed graphs before use, including every layer. + /// + /// # Errors + /// + /// Refuses missing applications, duplicate names and mismatched kinds. + pub fn validate(&self) -> Result<(), ComposedGraphError> { + if self.graph.is_empty() + || self.graph.len() > 128 + || !self + .graph + .iter() + .any(|node| matches!(node, GraphNode::Application { .. })) + { + return Err(ComposedGraphError::Graph); + } + let mut names = BTreeSet::new(); + for node in &self.graph { + let pin = node.pin(); + pin.validate()?; + if !names.insert(pin.name.as_str()) { + return Err(ComposedGraphError::Pin); + } + let correct_kind = match node { + GraphNode::Application { .. } => { + !pin.name.starts_with("runtime/") && !pin.name.starts_with("tool/") + } + GraphNode::Runtime { .. } => pin.name.starts_with("runtime/"), + GraphNode::Tool { .. } => pin.name.starts_with("tool/"), + }; + if !correct_kind { + return Err(ComposedGraphError::Kind); + } + } + Ok(()) + } + + /// Select a command, or `None` if no source declared one. + /// + /// The runtime override wins over the workload/config command; otherwise + /// the last *verified declaring* application wins, not necessarily the + /// last layer. The caller supplies default argv from the verified signed + /// manifest for each pin, or `None` if that manifest has no default. + /// This callback is a trust boundary: the contract does not verify packs. + /// The caller must also admit the selected command before execution. + /// + /// # Errors + /// + /// Refuses invalid directly constructed graphs or requests, even if an + /// override would otherwise conceal an invalid application default. + pub fn select_entrypoint<'a>( + &'a self, + request: &'a EntrypointRequest, + mut application_default: impl FnMut( + &SignedPackPin, + ) -> Result, ComposedGraphError>, + ) -> Result>, ComposedGraphError> { + self.validate()?; + request.validate()?; + let mut last_default = None; + for node in &self.graph { + if let GraphNode::Application { pin } = node + && let Some(argv) = application_default(pin)? + { + validate_command(argv)?; + last_default = Some(SelectedEntrypoint::Application { pin, argv }); + } + } + if let Some(argv) = &request.runtime_command_override { + return Ok(Some(SelectedEntrypoint::RuntimeOverride(argv))); + } + if let Some(argv) = &request.workload_command { + return Ok(Some(SelectedEntrypoint::Workload(argv))); + } + Ok(last_default) + } + + /// Deterministic authored JSON, preserving semantic layer order. + /// + /// # Errors + /// + /// Refuses invalid graph data or serialization failure. + pub fn canonical_json(&self) -> Result, ComposedGraphError> { + self.validate()?; + let mut value = + serde_json::to_value(self).map_err(|_| ComposedGraphError::Serialization)?; + value.sort_all_objects(); + serde_json::to_vec(&value).map_err(|_| ComposedGraphError::Serialization) + } +} + +impl EntrypointRequest { + /// Validate both command sources even if one takes precedence. + /// + /// # Errors + /// + /// Refuses unsafe or empty argv in either source. + pub fn validate(&self) -> Result<(), ComposedGraphError> { + for argv in [&self.runtime_command_override, &self.workload_command] + .into_iter() + .flatten() + { + validate_command(argv)?; + } + Ok(()) + } + + /// Deterministic request JSON, with absent commands omitted. + /// + /// # Errors + /// + /// Refuses invalid commands or serialization failure. + pub fn canonical_json(&self) -> Result, ComposedGraphError> { + self.validate()?; + let mut value = + serde_json::to_value(self).map_err(|_| ComposedGraphError::Serialization)?; + value.sort_all_objects(); + serde_json::to_vec(&value).map_err(|_| ComposedGraphError::Serialization) + } +} + +fn validate_command(argv: &[String]) -> Result<(), ComposedGraphError> { + let Some(program) = argv.first() else { + return Err(ComposedGraphError::Command); + }; + if argv.len() > 128 + || argv.iter().map(String::len).sum::() > 16 * 1024 + || !program.starts_with('/') + || !program.bytes().all(|byte| byte.is_ascii_graphic()) + || relative_path(&program[1..], false).is_err() + || argv + .iter() + .any(|argument| argument.is_empty() || argument.chars().any(char::is_control)) + { + return Err(ComposedGraphError::Command); + } + Ok(()) +} diff --git a/crates/mvm-contract/src/plan/bundle.rs b/crates/mvm-contract/src/plan/bundle.rs index 9683816e35..2840ba4a05 100644 --- a/crates/mvm-contract/src/plan/bundle.rs +++ b/crates/mvm-contract/src/plan/bundle.rs @@ -52,7 +52,9 @@ use crate::policy::security::AgentProfile; /// Version 6 separates the signed CLI runtime from the original Linux image set. /// Version 7 carries original canonical Linux publisher and revocation proofs. /// Version 8 binds a host-local composition to its authenticated input inventory. -pub const BUNDLE_SCHEMA_VERSION: u32 = 8; +/// Version 9 distinguishes a measured local development base from publisher proof. +/// Version 10 records a PR-only test-runtime signing ref in a host-signed development bundle. +pub const BUNDLE_SCHEMA_VERSION: u32 = 10; /// The first schema that may carry [`BundleMember::Environment`]. pub const ENVIRONMENT_MEMBER_MIN_SCHEMA: u32 = 5; @@ -69,6 +71,12 @@ pub const CANONICAL_LINUX_PROOF_MIN_SCHEMA: u32 = 7; /// The first schema that carries a host-local composition input inventory. pub const LOCAL_COMPOSITION_MIN_SCHEMA: u32 = 8; +/// The first schema that may carry measured, development-only base evidence. +pub const LOCAL_DEVELOPMENT_BASE_PROOF_MIN_SCHEMA: u32 = 9; + +/// A PR-workflow test runtime requires an explicit schema bump. +pub const PR_TEST_RUNTIME_MIN_SCHEMA: u32 = 10; + /// The first schema that may carry backend-neutral members. pub const BUNDLE_MEMBERS_MIN_SCHEMA: u32 = 3; @@ -204,6 +212,8 @@ pub enum BundleMember { /// Original canonical Linux root and revocation proof inventory references. /// Data to reverify at admission, never a publisher-authored permission. CanonicalLinuxProof(CanonicalLinuxProof), + /// A measured local source, not a publisher signature or release permission. + LocalDevelopmentBaseProof(LocalDevelopmentBaseProof), /// Development-only assembly inputs, not a publisher signature or a grant. /// The bundle's rootfs and verity declarations bind the measured output. LocalComposition(LocalComposition), @@ -232,8 +242,15 @@ impl BundleMember { pub const fn minimum_schema_version(&self) -> u32 { match self { Self::EmbeddedBootAssets { .. } => BOOT_ASSETS_MEMBER_MIN_SCHEMA, - Self::EmbeddedCliBootAssets(_) => CLI_BOOT_ASSETS_MEMBER_MIN_SCHEMA, + Self::EmbeddedCliBootAssets(runtime) => { + if runtime.pr_test_pull_request.is_some() { + PR_TEST_RUNTIME_MIN_SCHEMA + } else { + CLI_BOOT_ASSETS_MEMBER_MIN_SCHEMA + } + } Self::CanonicalLinuxProof(_) => CANONICAL_LINUX_PROOF_MIN_SCHEMA, + Self::LocalDevelopmentBaseProof(_) => LOCAL_DEVELOPMENT_BASE_PROOF_MIN_SCHEMA, Self::LocalComposition(_) => LOCAL_COMPOSITION_MIN_SCHEMA, Self::Environment(_) => ENVIRONMENT_MEMBER_MIN_SCHEMA, Self::EmbeddedImageSet { .. } @@ -262,6 +279,10 @@ pub struct EmbeddedCliBootAssets { pub overlay_roothash: String, pub protocol_version: u32, pub min_supported_protocol_version: u32, + /// Exact PR ref whose keyless workflow signed this synthetic runtime. + /// Never grants release provenance or substitutes for signature verification. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pr_test_pull_request: Option, } /// Archive inventory names, not host paths, trust roots or verification flags. @@ -277,6 +298,17 @@ pub struct CanonicalLinuxProof { pub revocation_signature_bundle_artifact: String, } +/// Exact local base manifest and external composition source for a same-host dev run. +/// The source path is never publisher authority; consumers rehash it at admission. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LocalDevelopmentBaseProof { + pub manifest_artifact: String, + pub composition_tar_sha256: String, + pub composition_tar_size: u64, + pub composition_tar_path: String, +} + /// Signed artifact names for the exact source lock and original layer evidence. /// External layer archives are not republished. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -545,14 +577,55 @@ mod tests { } #[test] - fn bundle_schema_version_is_eight() { + fn bundle_schema_version_is_ten() { // Pin the current version constant — bumps are deliberate; // a silent rev should trip this test. - assert_eq!(BUNDLE_SCHEMA_VERSION, 8); + assert_eq!(BUNDLE_SCHEMA_VERSION, 10); assert_eq!(ENVIRONMENT_MEMBER_MIN_SCHEMA, 5); assert_eq!(CLI_BOOT_ASSETS_MEMBER_MIN_SCHEMA, 6); assert_eq!(CANONICAL_LINUX_PROOF_MIN_SCHEMA, 7); assert_eq!(LOCAL_COMPOSITION_MIN_SCHEMA, 8); + assert_eq!(LOCAL_DEVELOPMENT_BASE_PROOF_MIN_SCHEMA, 9); + assert_eq!(PR_TEST_RUNTIME_MIN_SCHEMA, 10); + } + + #[test] + fn local_development_proof_is_closed_complete_and_requires_schema_nine() { + let value = serde_json::json!({ + "class": "local_development_base_proof", + "manifest_artifact": "boot-image-set.json", + "composition_tar_sha256": "a".repeat(64), + "composition_tar_size": 1024, + "composition_tar_path": "/local/base/rootfs.tar.gz" + }); + let member: BundleMember = serde_json::from_value(value.clone()).unwrap(); + assert_eq!(member.minimum_schema_version(), 9); + assert_eq!(serde_json::to_value(member).unwrap(), value); + for field in value + .as_object() + .unwrap() + .keys() + .filter(|key| *key != "class") + { + let mut missing = value.clone(); + missing.as_object_mut().unwrap().remove(field); + assert!( + serde_json::from_value::(missing).is_err(), + "{field}" + ); + } + for field in [ + "publisher_verified", + "release", + "revocation_document_artifact", + ] { + let mut forged = value.clone(); + forged[field] = true.into(); + assert!( + serde_json::from_value::(forged).is_err(), + "{field}" + ); + } } #[test] @@ -625,6 +698,11 @@ mod tests { let member: BundleMember = serde_json::from_value(value.clone()).unwrap(); assert_eq!(member.minimum_schema_version(), 6); assert_eq!(serde_json::to_value(member).unwrap(), value); + let mut pr_test = value.clone(); + pr_test["pr_test_pull_request"] = 42.into(); + let member: BundleMember = serde_json::from_value(pr_test.clone()).unwrap(); + assert_eq!(member.minimum_schema_version(), 10); + assert_eq!(serde_json::to_value(member).unwrap(), pr_test); for field in value .as_object() .unwrap() diff --git a/crates/mvm-contract/tests/pack_spec_v3.rs b/crates/mvm-contract/tests/pack_spec_v3.rs new file mode 100644 index 0000000000..9e2bd990f6 --- /dev/null +++ b/crates/mvm-contract/tests/pack_spec_v3.rs @@ -0,0 +1,334 @@ +use mvm_contract::pack_spec::PackSpec; +use mvm_contract::pack_spec_v2::ComposablePackSpec; +use mvm_contract::pack_spec_v3::{ + ComposedGraph, ComposedGraphError, ComposedRunRequest, EntrypointRequest, GraphNode, + SelectedEntrypoint, SignedPackPin, +}; +use serde_json::{Value, json}; + +fn graph() -> Value { + let digest = "a".repeat(64); + json!({ + "schema": "mvm.pack-spec/v3", + "graph": [ + {"kind": "runtime", "pin": {"name": "runtime/python", "version": "2.0.1", "manifest_sha256": digest}}, + {"kind": "tool", "pin": {"name": "tool/grep", "version": "1.0.0", "manifest_sha256": digest}}, + {"kind": "application", "pin": {"name": "agent/first", "version": "1.0.0", "manifest_sha256": digest}}, + {"kind": "application", "pin": {"name": "agent/last", "version": "1.0.0", "manifest_sha256": digest}} + ] + }) +} + +fn request(runtime: Option>, workload: Option>) -> EntrypointRequest { + serde_json::from_value(json!({ + "schema": "mvm.entrypoint-request/v1", + "runtime_command_override": runtime, + "workload_command": workload, + })) + .unwrap() +} + +fn verified_default<'a>( + pin: &SignedPackPin, + first: &'a [String], + last: &'a [String], +) -> Option<&'a [String]> { + match pin.name.as_str() { + "agent/first" => Some(first), + "agent/last" => Some(last), + _ => None, + } +} + +#[test] +fn precedence_is_independent_of_graph_order_and_all_eight_combinations() { + let graph: ComposedGraph = serde_json::from_value(graph()).unwrap(); + let first = vec!["/srv/app/first".into(), "--literal".into()]; + let last = vec!["/srv/app/last".into()]; + for runtime in [None, Some(vec!["/usr/bin/runtime"])] { + for workload in [None, Some(vec!["/usr/bin/workload"])] { + for declared in [false, true] { + let selection = request(runtime.clone(), workload.clone()); + let selected = graph + .select_entrypoint(&selection, |pin| { + Ok(if declared { + verified_default(pin, &first, &last) + } else { + None + }) + }) + .unwrap(); + match (runtime.is_some(), workload.is_some(), declared, selected) { + (true, _, _, Some(SelectedEntrypoint::RuntimeOverride(argv))) => { + assert_eq!(argv, ["/usr/bin/runtime"]); + } + (false, true, _, Some(SelectedEntrypoint::Workload(argv))) => { + assert_eq!(argv, ["/usr/bin/workload"]); + } + (false, false, true, Some(SelectedEntrypoint::Application { pin, argv })) => { + assert_eq!(pin.name, "agent/last"); + assert_eq!(argv, ["/srv/app/last"]); + } + (false, false, false, None) => {} + other => panic!("unexpected selection: {other:?}"), + } + } + } + } +} + +#[test] +fn last_declaring_application_wins_over_later_undeclared_application_and_tool() { + let mut doc = graph(); + let tool = doc["graph"][1].clone(); + doc["graph"].as_array_mut().unwrap().remove(1); + doc["graph"].as_array_mut().unwrap().push(tool); + let graph: ComposedGraph = serde_json::from_value(doc).unwrap(); + let selection = request(None, None); + let first = vec!["/srv/app/first".into(), "--literal".into()]; + assert!(matches!( + graph.select_entrypoint(&selection, |pin| { + Ok((pin.name == "agent/first").then_some(first.as_slice())) + }), + Ok(Some(SelectedEntrypoint::Application { pin, argv })) + if pin.name == "agent/first" && argv == ["/srv/app/first", "--literal"] + )); +} + +#[test] +fn no_implicit_service_or_tool_startup() { + let no_commands: ComposedGraph = serde_json::from_value(graph()).unwrap(); + let selection = request(None, None); + assert_eq!( + no_commands.select_entrypoint(&selection, |_| Ok(None)), + Ok(None) + ); + let mut doc = graph(); + doc["graph"][2]["command"] = json!(["/srv/app/bogus"]); + assert!(serde_json::from_value::(doc).is_err()); + let mut doc = graph(); + doc["graph"][0]["command"] = json!(["/usr/bin/python3"]); + assert!(serde_json::from_value::(doc).is_err()); + let mut doc = graph(); + doc["graph"][1]["services"] = json!(["daemon"]); + assert!(serde_json::from_value::(doc).is_err()); + let mut doc = graph(); + doc["services"] = json!(["daemon"]); + assert!(serde_json::from_value::(doc).is_err()); + let mut doc = graph(); + doc["graph"].as_array_mut().unwrap().remove(3); + let graph: ComposedGraph = serde_json::from_value(doc).unwrap(); + assert_eq!(graph.select_entrypoint(&selection, |_| Ok(None)), Ok(None)); + assert_eq!( + graph.select_entrypoint(&request(Some(vec!["/bin/explicit"]), None), |_| { + Err(ComposedGraphError::Pin) + }), + Err(ComposedGraphError::Pin), + "an unverified application cannot be hidden by a command override" + ); +} + +#[test] +fn pins_are_exact_signed_manifest_references_and_conflicting_names_are_rejected() { + for (field, bad) in [ + ("manifest_sha256", json!("A".repeat(64))), + ("manifest_sha256", json!("f".repeat(63))), + ("manifest_sha256", json!(format!("{}g", "a".repeat(63)))), + ("version", json!("latest")), + ("version", json!("1.0.0+local")), + ("name", json!("runtime/python@2.0.1")), + ] { + let mut doc = graph(); + doc["graph"][0]["pin"][field] = bad; + assert!( + serde_json::from_value::(doc).is_err(), + "{field}" + ); + } + for field in ["signature", "publisher", "path", "digest"] { + let mut doc = graph(); + doc["graph"][0]["pin"][field] = json!("unauthorized"); + assert!( + serde_json::from_value::(doc).is_err(), + "{field}" + ); + } + let mut doc = graph(); + let duplicate = doc["graph"][0].clone(); + doc["graph"].as_array_mut().unwrap().push(duplicate); + assert!(serde_json::from_value::(doc).is_err()); + let mut doc = graph(); + doc["graph"][0]["pin"]["version"] = json!("3.0.0"); + let duplicate = doc["graph"][0].clone(); + doc["graph"].as_array_mut().unwrap().push(duplicate); + assert!(serde_json::from_value::(doc).is_err()); + let mut doc = graph(); + doc["graph"][3]["pin"]["name"] = json!("agent/first"); + assert!(serde_json::from_value::(doc).is_err()); + let mut doc = graph(); + doc["graph"][3]["pin"]["manifest_sha256"] = json!("b".repeat(64)); + doc["graph"][3]["pin"]["name"] = json!("agent/first"); + assert!(serde_json::from_value::(doc).is_err()); +} + +#[test] +fn closed_versions_roles_and_unsafe_commands_fail_on_deserialize_and_direct_construction() { + for schema in ["mvm.pack-spec/v1", "mvm.pack-spec/v2", "mvm.pack-spec/v4"] { + let mut doc = graph(); + doc["schema"] = json!(schema); + assert!(serde_json::from_value::(doc).is_err()); + } + assert!(serde_json::from_value::(graph()).is_err()); + assert!(serde_json::from_value::(graph()).is_err()); + for invalid_graph in [json!([]), json!([graph()["graph"][0].clone()])] { + let mut doc = graph(); + doc["graph"] = invalid_graph; + assert!(serde_json::from_value::(doc).is_err()); + } + let mut doc = graph(); + doc["graph"][0]["pin"]["unexpected"] = json!(true); + assert!(serde_json::from_value::(doc).is_err()); + for (index, name) in [(0, "agent/first"), (1, "runtime/python"), (2, "tool/grep")] { + let mut doc = graph(); + doc["graph"][index]["pin"]["name"] = json!(name); + assert!(serde_json::from_value::(doc).is_err()); + } + for bad in [ + json!([]), + json!(["relative"]), + json!(["//usr/bin/app"]), + json!(["/srv/app/../app"]), + json!(["/srv/app/run\nnow"]), + json!(["/srv/app/run", "a\0b"]), + json!(["/srv/app/run", "x".repeat(16 * 1024)]), + ] { + let mut req = json!({"schema": "mvm.entrypoint-request/v1"}); + req["runtime_command_override"] = bad.clone(); + assert!(serde_json::from_value::(req).is_err()); + let graph: ComposedGraph = serde_json::from_value(graph()).unwrap(); + let selection = request(Some(vec!["/bin/safe"]), None); + let argv: Vec = serde_json::from_value(bad).unwrap(); + assert_eq!( + graph.select_entrypoint(&selection, |_| Ok(Some(argv.as_slice()))), + Err(ComposedGraphError::Command), + "invalid verified default must not be hidden by override" + ); + } + let mut graph: ComposedGraph = serde_json::from_value(graph()).unwrap(); + if let GraphNode::Application { pin } = &mut graph.graph[2] { + pin.manifest_sha256 = "0".into(); + } + assert_eq!(graph.validate(), Err(ComposedGraphError::Pin)); + assert!(graph.canonical_json().is_err()); + let mut req = request(None, None); + req.workload_command = Some(vec!["relative".into()]); + assert!(req.validate().is_err()); + assert!(graph.select_entrypoint(&req, |_| Ok(None)).is_err()); + let mut req = request(Some(vec!["/bin/safe"]), None); + req.workload_command = Some(vec!["relative".into()]); + assert!(req.validate().is_err(), "lower-priority invalid command"); + assert_eq!( + serde_json::from_value::(json!({ + "schema": "mvm.entrypoint-request/v1", + "runtime_command_override": ["/usr/bin/sh", "-c", "echo allowed"] + })) + .unwrap() + .validate(), + Ok(()), + "explicit shell use is governed by downstream admission, not this contract" + ); + assert!( + serde_json::from_str::( + r#"{"schema":"mvm.entrypoint-request/v1","schema":"mvm.entrypoint-request/v1"}"# + ) + .is_err() + ); +} + +#[test] +fn canonical_roundtrip_preserves_order_and_no_command_does_not_materialize() { + let graph: ComposedGraph = serde_json::from_value(graph()).unwrap(); + let bytes = graph.canonical_json().unwrap(); + let roundtrip: ComposedGraph = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(roundtrip, graph); + assert_eq!(roundtrip.graph, graph.graph); + let request = request(None, None); + let bytes = request.canonical_json().unwrap(); + assert_eq!( + serde_json::from_slice::(&bytes).unwrap(), + request + ); + assert!( + serde_json::from_slice::(br#"{"schema":"mvm.entrypoint-request/v2"}"#) + .is_err() + ); + assert!( + serde_json::from_slice::( + br#"{"schema":"mvm.entrypoint-request/v1","services":[]}"# + ) + .is_err() + ); + let toml = r#" +schema = "mvm.pack-spec/v3" + +[[graph]] +kind = "application" +[graph.pin] +name = "agent/example" +version = "1.0.0" +manifest_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +"#; + let from_toml: ComposedGraph = toml::from_str(toml).unwrap(); + assert_eq!( + from_toml.graph[0].pin().name, + "agent/example", + "TOML and JSON share the same closed model" + ); + assert_eq!( + toml::from_str::( + "schema = \"mvm.entrypoint-request/v1\"\nworkload_command = [\"/srv/app/run\"]" + ) + .unwrap() + .workload_command, + Some(vec!["/srv/app/run".into()]) + ); +} + +#[test] +fn one_file_invocation_preserves_order_target_and_entrypoint_without_granting_services() { + let value = json!({ + "schema": "mvm.composed-run/v1", + "target": "x86_64-linux", + "graph": graph(), + "entrypoint": { + "schema": "mvm.entrypoint-request/v1", + "workload_command": ["/srv/app/run"] + } + }); + let request: ComposedRunRequest = serde_json::from_value(value.clone()).unwrap(); + request.validate().unwrap(); + assert_eq!(serde_json::to_value(&request).unwrap(), value); + assert_eq!(request.graph.graph[0].pin().name, "runtime/python"); + assert_eq!( + request.entrypoint.workload_command.as_deref().unwrap(), + ["/srv/app/run"] + ); + + for (field, invalid) in [ + ("schema", json!("mvm.composed-run/v2")), + ("target", json!("wasm32-wasi")), + ] { + let mut changed = value.clone(); + changed[field] = invalid; + assert!( + serde_json::from_value::(changed).is_err(), + "{field}" + ); + } + let mut unknown = value.clone(); + unknown["services"] = json!(["unrequested-daemon"]); + assert!(serde_json::from_value::(unknown).is_err()); + let mut invalid = request; + invalid.graph.graph.clear(); + assert!(invalid.validate().is_err()); +} diff --git a/crates/mvm-core/Cargo.toml b/crates/mvm-core/Cargo.toml index df591a8be9..ed002e1fdc 100644 --- a/crates/mvm-core/Cargo.toml +++ b/crates/mvm-core/Cargo.toml @@ -168,6 +168,8 @@ client-remote = ["client", "dep:mvm-http"] # Expose `plan::signing::test_support` (shared `ExecutionPlan` fixtures) to # other crates' test builds without shipping the fixtures in a release build. test-support = [] +# PR-workflow guest runtime evidence; never enabled by ordinary builds. +pr-test-guest-runtime = [] # The hostd IPC async transport in `protocol` (mvmd ↔ mvm-hostd control # channel: read/write_frame + send/recv request/response). The only # async surface in mvm-core besides `manifest-verify`; gated so the diff --git a/crates/mvm-core/src/canonical_linux.rs b/crates/mvm-core/src/canonical_linux.rs index d023abcf72..2ed3519a3d 100644 --- a/crates/mvm-core/src/canonical_linux.rs +++ b/crates/mvm-core/src/canonical_linux.rs @@ -574,6 +574,7 @@ mod tests { overlay_roothash: "a".repeat(64), protocol_version: 2, min_supported_protocol_version: 2, + pr_test_pull_request: None, }; let mut members = vec![BundleMember::EmbeddedCliBootAssets(runtime)]; if canonical { diff --git a/crates/mvm-core/src/image_set.rs b/crates/mvm-core/src/image_set.rs index cbe2ea1351..60f9ae25fd 100644 --- a/crates/mvm-core/src/image_set.rs +++ b/crates/mvm-core/src/image_set.rs @@ -21,6 +21,7 @@ use crate::arch::GuestArch; use crate::kernel_format::KernelFormat; use crate::packs::{FlakeLockIdentity, SbomReference, Sha256Hex, SourceRevisionIdentity}; +pub mod base_compatibility; mod base_contract; mod boot_abi; mod checkout; diff --git a/crates/mvm-core/src/image_set/base_compatibility.rs b/crates/mvm-core/src/image_set/base_compatibility.rs new file mode 100644 index 0000000000..0a994c4b21 --- /dev/null +++ b/crates/mvm-core/src/image_set/base_compatibility.rs @@ -0,0 +1,1136 @@ +//! Verify a composable base descriptor against independently authenticated image-set bytes. +//! +//! The descriptor does not extend the image-set schema or grant itself authority. +//! Its unsigned form is usable only for local development. + +use std::collections::HashSet; + +use mvm_contract::guest_libc::GuestLibc; +use serde::Deserialize; +use thiserror::Error; + +use super::{ + ArtifactFormat, ArtifactName, BaseImageContract, BootProtocol, ImageSetManifest, ImageSetRole, + ImageSetVerification, LocalImageSetVerification, MemberTarget, VerifiedArtifact, + WorkloadImageProfile, verify_image_set_artifacts, verify_local_image_set, +}; +use crate::arch::GuestArch; +use crate::crypto::image_verify::verify_signed_payload_under_any_identity; +use crate::image_set_revocation::{ + ImageSetRevocationStoreError, VerifiedImageSetRevocations, load_image_set_revocations, +}; +use crate::packs::{KeylessTrust, PackRevocationChecker, RevocationStatus, Sha256Hex}; +#[cfg(feature = "host-state")] +use crate::registry_pack::application_component::ComponentRequirements; + +const MAX_DESCRIPTOR_BYTES: usize = 64 * 1024; +const MAX_BUNDLE_BYTES: usize = 1024 * 1024; +const KERNEL_CAPABILITIES: [&str; 8] = [ + "virtio_vsock", + "virtio_blk", + "dm_verity", + "pty", + "namespaces", + "cgroup_v2", + "fuse", + "overlayfs", +]; + +/// A versioned, syntactically checked declaration; this alone is not trust evidence. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BaseCompatibility { + schema: String, + image_set_manifest_sha256: Sha256Hex, + arch: GuestArch, + profile: WorkloadImageProfile, + kernel: ArtifactPin, + composition_tar: ArtifactPin, + base_image_contract: BaseImageContract, + boot_protocol: BootProtocol, + filesystem_abi: u32, + kernel_version: String, + libc_loaders: Vec, + kernel_capabilities: Vec, +} + +/// An exact published artifact pin. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ArtifactPin { + name: ArtifactName, + sha256: Sha256Hex, + size: u64, +} + +/// A declared dynamic loader in the base filesystem. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LibcLoader { + libc: GuestLibc, + path: String, +} + +/// A separately signed descriptor tied to authenticated, revocation-checked member bytes. +#[derive(Debug)] +pub struct ProductionBaseCompatibility { + descriptor: BaseCompatibility, + descriptor_bytes: Vec, + signature_bundle: Vec, + kernel: VerifiedArtifact, + composition_tar: VerifiedArtifact, +} + +/// An unsigned descriptor tied to measured local member bytes, never production authority. +#[derive(Debug)] +pub struct DevelopmentBaseCompatibility { + descriptor: BaseCompatibility, + descriptor_bytes: Vec, + kernel: VerifiedArtifact, + composition_tar: VerifiedArtifact, +} + +impl ProductionBaseCompatibility { + /// The authenticated declaration. + pub fn descriptor(&self) -> &BaseCompatibility { + &self.descriptor + } + + /// Exact descriptor bytes authenticated by the detached signature. + pub fn descriptor_bytes(&self) -> &[u8] { + &self.descriptor_bytes + } + + /// Original detached signature bundle, for a consumer retaining cache evidence. + pub fn signature_bundle(&self) -> &[u8] { + &self.signature_bundle + } + + /// The two member files whose bytes were checked. + pub fn artifacts(&self) -> (&VerifiedArtifact, &VerifiedArtifact) { + (&self.kernel, &self.composition_tar) + } + + /// Recheck the held signed bytes against a fresh consumer-held image-set source + /// and the currently applied checkpointed revocation feed. Use the returned + /// artifacts rather than paths from the earlier verification. + /// + /// # Errors + /// Refuses a stale or missing feed, changed member bytes, or invalid signatures. + pub fn reverify( + &self, + image_set: &ImageSetVerification<'_>, + ) -> Result { + self.reverify_with( + image_set, + load_image_set_revocations, + Checks { + signature: verify_descriptor_signature, + image_set: verify_image_set_artifacts, + }, + ) + } + + fn reverify_with( + &self, + image_set: &ImageSetVerification<'_>, + load: impl FnOnce() -> Result, + checks: Checks, + ) -> Result { + let revocations = load()?; + verify_production_with( + &self.descriptor_bytes, + &self.signature_bundle, + image_set, + self.descriptor.arch, + self.descriptor.profile, + &revocations, + checks, + ) + } + + /// Check app or runtime/tool requirements against the authenticated declaration. + /// + /// # Errors + /// Refuses unknown, noncanonical or unsatisfied requirements. + #[cfg(feature = "host-state")] + pub fn check_component_requirements( + &self, + requirements: &ComponentRequirements, + arch: GuestArch, + ) -> Result<(), BaseCompatibilityError> { + self.descriptor.check_requirements(requirements, arch) + } +} + +impl DevelopmentBaseCompatibility { + /// The unsigned local declaration. Its kernel claims are not boot witnesses. + pub fn descriptor(&self) -> &BaseCompatibility { + &self.descriptor + } + + /// Exact unsigned local descriptor bytes; never production evidence. + pub fn descriptor_bytes(&self) -> &[u8] { + &self.descriptor_bytes + } + + /// The two measured local member files. + pub fn artifacts(&self) -> (&VerifiedArtifact, &VerifiedArtifact) { + (&self.kernel, &self.composition_tar) + } + + /// Recheck the held unsigned declaration against a fresh local checkout + /// snapshot and measured member bytes. This remains Development evidence. + /// + /// # Errors + /// Refuses a changed checkout, manifest, or selected member bytes. + pub fn reverify( + &self, + image_set: &LocalImageSetVerification<'_>, + ) -> Result { + verify_development_base_compatibility( + &self.descriptor_bytes, + image_set, + self.descriptor.arch, + self.descriptor.profile, + ) + } + + /// Check requirements against measured local bytes and unsigned base claims. + /// + /// # Errors + /// Refuses unknown, noncanonical or unsatisfied requirements; success remains Development. + #[cfg(feature = "host-state")] + pub fn check_component_requirements( + &self, + requirements: &ComponentRequirements, + arch: GuestArch, + ) -> Result<(), BaseCompatibilityError> { + self.descriptor.check_requirements(requirements, arch) + } +} + +impl BaseCompatibility { + /// Strictly parse the bounded v1 JSON and validate every declared value. + /// + /// # Errors + /// Returns an error for malformed, unsupported or unbounded declarations. + pub fn parse(bytes: &[u8]) -> Result { + if bytes.len() > MAX_DESCRIPTOR_BYTES { + return Err(BaseCompatibilityError::Invalid( + "descriptor too large".into(), + )); + } + let descriptor: Self = serde_json::from_slice(bytes) + .map_err(|e| BaseCompatibilityError::Invalid(e.to_string()))?; + descriptor.validate()?; + Ok(descriptor) + } + + /// Claimed kernel version, in canonical major.minor.patch form. + pub fn kernel_version(&self) -> &str { + &self.kernel_version + } + + /// Guest architecture bound to the selected member pair. + pub fn arch(&self) -> GuestArch { + self.arch + } + + /// Generic workload profile bound to the selected member pair. + pub fn profile(&self) -> WorkloadImageProfile { + self.profile + } + + /// Digest of the exact signed or locally measured image-set manifest. + pub fn image_set_manifest_sha256(&self) -> &Sha256Hex { + &self.image_set_manifest_sha256 + } + + /// Claimed loader paths; a signed descriptor is not a byte-level loader witness. + pub fn libc_loaders(&self) -> &[LibcLoader] { + &self.libc_loaders + } + + /// Claimed kernel capabilities. + pub fn kernel_capabilities(&self) -> &[String] { + &self.kernel_capabilities + } + + fn validate(&self) -> Result<(), BaseCompatibilityError> { + let invalid = |reason: &str| BaseCompatibilityError::Invalid(reason.into()); + if self.schema != "base-compatibility/v1" { + return Err(invalid("unsupported descriptor schema")); + } + if self.base_image_contract != BaseImageContract::LINUX_LAYER + || self.boot_protocol != BootProtocol::LinuxDirect + || self.filesystem_abi != 1 + { + return Err(invalid("unsupported base, boot or filesystem ABI")); + } + if self.kernel.name == self.composition_tar.name + || self.kernel.size == 0 + || self.composition_tar.size == 0 + { + return Err(invalid("invalid artifact pins")); + } + if parse_kernel_version(&self.kernel_version).is_none_or(|version| version[0] == 0) { + return Err(invalid( + "kernel_version must be canonical major.minor.patch", + )); + } + if self.libc_loaders.is_empty() || self.libc_loaders.len() > 2 { + return Err(invalid("libc_loaders must contain one or two entries")); + } + let mut libcs = HashSet::new(); + for loader in &self.libc_loaders { + if loader.libc == GuestLibc::Unknown || !libcs.insert(loader.libc.as_str()) { + return Err(invalid("duplicate or unknown libc loader")); + } + let path = loader.path.as_str(); + if path.len() > 256 + || !path.starts_with('/') + || path.contains("//") + || path.split('/').skip(1).any(|part| { + part.is_empty() + || part == "." + || part == ".." + || !part.bytes().all(|b| { + b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.' | b'+') + }) + }) + { + return Err(invalid("invalid absolute libc loader path")); + } + } + if self.kernel_capabilities.len() > KERNEL_CAPABILITIES.len() { + return Err(invalid("too many kernel capabilities")); + } + let mut names = HashSet::new(); + for name in &self.kernel_capabilities { + if !KERNEL_CAPABILITIES.contains(&name.as_str()) || !names.insert(name) { + return Err(invalid("unsupported or duplicate kernel capability")); + } + } + Ok(()) + } + + #[cfg(feature = "host-state")] + fn check_requirements( + &self, + requirements: &ComponentRequirements, + arch: GuestArch, + ) -> Result<(), BaseCompatibilityError> { + let mismatch = BaseCompatibilityError::Mismatch; + if arch != self.arch { + return Err(mismatch("guest architecture")); + } + if requirements.filesystem_abi != self.filesystem_abi { + return Err(mismatch("filesystem ABI")); + } + let minimum = parse_kernel_version(&requirements.minimum_kernel) + .ok_or(mismatch("invalid minimum kernel version"))?; + let base = parse_kernel_version(&self.kernel_version) + .ok_or(mismatch("invalid base kernel version"))?; + if base < minimum { + return Err(mismatch("minimum kernel version")); + } + match requirements.libc.as_str() { + "static" if requirements.loader.is_none() => {} + "musl" | "glibc" => { + let loader = requirements + .loader + .as_deref() + .ok_or(mismatch("libc loader"))?; + if !self + .libc_loaders + .iter() + .any(|entry| entry.libc.as_str() == requirements.libc && entry.path == loader) + { + return Err(mismatch("libc loader")); + } + } + _ => return Err(mismatch("unsupported libc or static loader")), + } + if requirements.required_capabilities.len() > KERNEL_CAPABILITIES.len() { + return Err(mismatch("too many required kernel capabilities")); + } + let mut names = HashSet::new(); + for name in &requirements.required_capabilities { + if !KERNEL_CAPABILITIES.contains(&name.as_str()) + || !names.insert(name) + || !self.kernel_capabilities.contains(name) + { + return Err(mismatch("unsupported or missing kernel capability")); + } + } + Ok(()) + } +} + +fn parse_kernel_version(version: &str) -> Option<[u32; 3]> { + let mut parts = version.split('.'); + let mut parsed = [0; 3]; + for slot in &mut parsed { + let part = parts.next()?; + if part.is_empty() + || (part.len() > 1 && part.starts_with('0')) + || !part.bytes().all(|c| c.is_ascii_digit()) + { + return None; + } + *slot = part.parse().ok()?; + } + parts.next().is_none().then_some(parsed) +} + +impl LibcLoader { + /// C library this loader serves. + pub fn libc(&self) -> GuestLibc { + self.libc + } + + /// Absolute guest path declared by the descriptor. + pub fn path(&self) -> &str { + &self.path + } +} + +/// Why a descriptor cannot authorize the selected base. +#[derive(Debug, Error)] +pub enum BaseCompatibilityError { + #[error("invalid base compatibility descriptor: {0}")] + Invalid(String), + #[error("base descriptor signature invalid: {0}")] + Signature(String), + #[error("image-set verification failed: {0}")] + ImageSet(#[from] super::ImageSetError), + #[error("image-set revocations unavailable or stale: {0}")] + Revocations(#[from] ImageSetRevocationStoreError), + #[error("descriptor revoked: {0}")] + Revoked(String), + #[error("descriptor and selected image-set members disagree: {0}")] + Mismatch(&'static str), +} + +/// Verify separately signed descriptor bytes and signed, pinned selected image-set files. +/// +/// Loads the applied revocation feed afresh; no supplied or host-claimed authority +/// can replace the lock's release identity or the persisted feed checkpoint. +/// +/// # Errors +/// Refuses any signature, feed, lock, selection, syntax or byte mismatch. +pub fn verify_production_base_compatibility( + bytes: &[u8], + signature_bundle: &[u8], + image_set: &ImageSetVerification<'_>, + expected_arch: GuestArch, + expected_profile: WorkloadImageProfile, +) -> Result { + let revocations = load_image_set_revocations()?; + verify_production_with( + bytes, + signature_bundle, + image_set, + expected_arch, + expected_profile, + &revocations, + Checks { + signature: verify_descriptor_signature, + image_set: verify_image_set_artifacts, + }, + ) +} + +type SignatureCheck = fn(&[u8], &[u8], &KeylessTrust) -> Result<(), String>; +type SetCheck = fn( + &ImageSetVerification<'_>, + &[ArtifactName], +) -> Result; + +struct Checks { + signature: SignatureCheck, + image_set: SetCheck, +} + +fn verify_production_with( + bytes: &[u8], + signature_bundle: &[u8], + image_set: &ImageSetVerification<'_>, + expected_arch: GuestArch, + expected_profile: WorkloadImageProfile, + revocations: &VerifiedImageSetRevocations, + checks: Checks, +) -> Result { + if signature_bundle.len() > MAX_BUNDLE_BYTES { + return Err(BaseCompatibilityError::Invalid( + "signature bundle too large".into(), + )); + } + let descriptor = BaseCompatibility::parse(bytes)?; + if descriptor.arch != expected_arch || descriptor.profile != expected_profile { + return Err(BaseCompatibilityError::Mismatch("architecture or profile")); + } + // The verifier accepts this trust only from the release lock, never from descriptor JSON. + let trust = image_set.lock_trust(); + (checks.signature)(bytes, signature_bundle, &trust) + .map_err(BaseCompatibilityError::Signature)?; + let selected = [ + descriptor.kernel.name.clone(), + descriptor.composition_tar.name.clone(), + ]; + let checked = (checks.image_set)(&image_set.with_current_revocations(revocations), &selected)?; + let signer = checked.signer_key_id; + if let RevocationStatus::Revoked { reason } = + revocations.status(&signer, &Sha256Hex::from_bytes(bytes)) + { + return Err(BaseCompatibilityError::Revoked(reason)); + } + let (kernel, tar) = bind( + &descriptor, + &checked.manifest, + &checked.manifest_sha256, + &checked.artifacts, + )?; + Ok(ProductionBaseCompatibility { + descriptor, + descriptor_bytes: bytes.to_vec(), + signature_bundle: signature_bundle.to_vec(), + kernel, + composition_tar: tar, + }) +} + +fn verify_descriptor_signature( + bytes: &[u8], + bundle: &[u8], + trust: &KeylessTrust, +) -> Result<(), String> { + let identities: Vec<&str> = trust + .accepted_identities + .iter() + .map(String::as_str) + .collect(); + verify_signed_payload_under_any_identity(bytes, bundle, &identities, &trust.issuer) + .map_err(|e| e.to_string()) +} + +/// Verify unsigned local syntax and exact local image-set member bytes. +/// +/// Never upgrades a locally built set or its descriptor to Production. +/// +/// # Errors +/// Refuses malformed descriptor, stale local image set, or mismatched artifacts. +pub fn verify_development_base_compatibility( + bytes: &[u8], + image_set: &LocalImageSetVerification<'_>, + expected_arch: GuestArch, + expected_profile: WorkloadImageProfile, +) -> Result { + let descriptor = BaseCompatibility::parse(bytes)?; + if descriptor.arch != expected_arch || descriptor.profile != expected_profile { + return Err(BaseCompatibilityError::Mismatch("architecture or profile")); + } + let checked = verify_local_image_set(image_set)?; + let (kernel, tar) = bind( + &descriptor, + &checked.manifest, + &checked.manifest_sha256, + &checked.artifacts, + )?; + Ok(DevelopmentBaseCompatibility { + descriptor, + descriptor_bytes: bytes.to_vec(), + kernel, + composition_tar: tar, + }) +} + +fn bind( + descriptor: &BaseCompatibility, + manifest: &ImageSetManifest, + root_sha: &Sha256Hex, + artifacts: &[VerifiedArtifact], +) -> Result<(VerifiedArtifact, VerifiedArtifact), BaseCompatibilityError> { + let mismatch = BaseCompatibilityError::Mismatch; + if &descriptor.image_set_manifest_sha256 != root_sha { + return Err(mismatch("image-set manifest digest")); + } + if manifest.schema_version != super::IMAGE_SET_SCHEMA_VERSION + || manifest.compatibility.base_image_contract != Some(BaseImageContract::LINUX_LAYER) + { + return Err(mismatch("schema 3 Linux base contract required")); + } + let target = MemberTarget::Arch(descriptor.arch); + let find = |role, pin: &ArtifactPin, format: fn(ArtifactFormat) -> bool| { + let member = manifest + .members + .iter() + .find(|member| { + member.role == role + && member.target == target + && member.build_mode.is_none() + && member.artifacts.iter().any(|artifact| { + artifact.name == pin.name + && format(artifact.format) + && artifact.sha256 == pin.sha256 + && artifact.size == pin.size + }) + }) + .ok_or(mismatch("selected member role, format or artifact pin"))?; + if role == ImageSetRole::WorkloadKernel(descriptor.profile) + && member.boot_protocol != Some(BootProtocol::LinuxDirect) + { + return Err(mismatch("kernel boot protocol")); + } + artifacts + .iter() + .find(|artifact| { + artifact.name == pin.name + && artifact.role == role + && artifact.target == target + && artifact.sha256 == pin.sha256 + && artifact.size == pin.size + }) + .cloned() + .ok_or(mismatch("selected artifact bytes")) + }; + Ok(( + find( + ImageSetRole::WorkloadKernel(descriptor.profile), + &descriptor.kernel, + |format| matches!(format, ArtifactFormat::Kernel(_)), + )?, + find( + ImageSetRole::WorkloadRootfs(descriptor.profile), + &descriptor.composition_tar, + |format| format == ArtifactFormat::TarGz, + )?, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::image_set::MemberArtifact; + use crate::kernel_format::KernelFormat; + + fn fixture() -> ( + BaseCompatibility, + ImageSetManifest, + Sha256Hex, + Vec, + tempfile::TempDir, + ) { + let dir = tempfile::tempdir().expect("tempdir"); + let mut manifest = super::super::tests::manifest(); + manifest.schema_version = super::super::IMAGE_SET_SCHEMA_VERSION; + manifest.mvm_source_commit = None; + manifest.compatibility.guest_agent_protocol = None; + manifest.compatibility.base_image_contract = Some(BaseImageContract::LINUX_LAYER); + manifest.compatibility.builder_boot_abi = Some(super::super::BuilderBootAbi::NO_MVM_BINARY); + let arch = GuestArch::X86_64; + let profile = WorkloadImageProfile::DefaultTenant; + let target = MemberTarget::Arch(arch); + let kernel_name = ArtifactName::new("kernel.elf").expect("kernel name"); + let tar_name = ArtifactName::new("base.tar.gz").expect("tar name"); + let kernel_bytes = b"kernel bytes"; + let tar_bytes = b"tar bytes"; + std::fs::write(dir.path().join(kernel_name.as_str()), kernel_bytes).expect("kernel"); + std::fs::write(dir.path().join(tar_name.as_str()), tar_bytes).expect("tar"); + let kernel_sha = Sha256Hex::from_bytes(kernel_bytes); + let tar_sha = Sha256Hex::from_bytes(tar_bytes); + let kernel = manifest + .members + .iter_mut() + .find(|member| { + member.role == ImageSetRole::WorkloadKernel(profile) && member.target == target + }) + .expect("kernel member"); + kernel.artifacts = vec![MemberArtifact { + name: kernel_name.clone(), + format: ArtifactFormat::Kernel(KernelFormat::Elf), + sha256: kernel_sha.clone(), + size: kernel_bytes.len() as u64, + }]; + let rootfs = manifest + .members + .iter_mut() + .find(|member| { + member.role == ImageSetRole::WorkloadRootfs(profile) && member.target == target + }) + .expect("rootfs member"); + rootfs.artifacts.push(MemberArtifact { + name: tar_name.clone(), + format: ArtifactFormat::TarGz, + sha256: tar_sha.clone(), + size: tar_bytes.len() as u64, + }); + let root_sha = Sha256Hex::from_bytes(&serde_json::to_vec(&manifest).expect("manifest")); + let bytes = serde_json::to_vec(&serde_json::json!({ + "schema": "base-compatibility/v1", + "image_set_manifest_sha256": root_sha, + "arch": "x86_64", + "profile": "default_tenant", + "kernel": { "name": kernel_name, "sha256": kernel_sha, "size": kernel_bytes.len() }, + "composition_tar": { "name": tar_name, "sha256": tar_sha, "size": tar_bytes.len() }, + "base_image_contract": 1, + "boot_protocol": "linux_direct", + "filesystem_abi": 1, + "kernel_version": "6.12.3", + "libc_loaders": [{"libc": "musl", "path": "/lib/ld-musl-x86_64.so.1"}], + "kernel_capabilities": ["virtio_vsock", "virtio_blk"] + })) + .expect("descriptor"); + let descriptor = BaseCompatibility::parse(&bytes).expect("descriptor parses"); + let artifacts = [ + (&descriptor.kernel, ImageSetRole::WorkloadKernel(profile)), + ( + &descriptor.composition_tar, + ImageSetRole::WorkloadRootfs(profile), + ), + ] + .into_iter() + .map(|(pin, role)| VerifiedArtifact { + role, + target, + name: pin.name.clone(), + path: dir.path().join(pin.name.as_str()), + sha256: pin.sha256.clone(), + size: pin.size, + }) + .collect(); + (descriptor, manifest, root_sha, artifacts, dir) + } + + #[test] + fn rejects_unknown_and_malformed_wire_values() { + let (good, _, _, _, _) = fixture(); + let value = serde_json::to_value(serde_json::json!({ + "schema": good.schema, "image_set_manifest_sha256": good.image_set_manifest_sha256, + "arch": good.arch, "profile": good.profile, + "kernel": {"name": good.kernel.name, "sha256": good.kernel.sha256, "size": good.kernel.size}, + "composition_tar": {"name": good.composition_tar.name, "sha256": good.composition_tar.sha256, "size": good.composition_tar.size}, + "base_image_contract": 1, "boot_protocol": "linux_direct", "filesystem_abi": 1, + "kernel_version": "6.12.3", + "libc_loaders": [{"libc": "musl", "path": "/lib/ld-musl-x86_64.so.1"}], + "kernel_capabilities": ["virtio_vsock"] + })).expect("json"); + for (field, bad) in [ + ("schema", serde_json::json!("base-compatibility/v2")), + ("kernel_version", serde_json::json!("6.12")), + ("kernel_version", serde_json::json!("06.12.3")), + ("kernel_version", serde_json::json!("6.12.3-rc1")), + ("base_image_contract", serde_json::json!(0)), + ("filesystem_abi", serde_json::json!(2)), + ("boot_protocol", serde_json::json!("uefi")), + ( + "libc_loaders", + serde_json::json!([{"libc":"glibc","path":"../ld.so"}]), + ), + ( + "libc_loaders", + serde_json::json!([{"libc":"musl","path":"/lib/ld.so"},{"libc":"musl","path":"/lib/ld2.so"}]), + ), + ("kernel_capabilities", serde_json::json!(["guest_nic"])), + ( + "kernel_capabilities", + serde_json::json!(["virtio_blk", "virtio_blk"]), + ), + ("trust", serde_json::json!("production")), + ] { + let mut candidate = value.clone(); + candidate[field] = bad; + assert!( + BaseCompatibility::parse(&serde_json::to_vec(&candidate).expect("bytes")).is_err(), + "{field}: {candidate}" + ); + } + assert!(BaseCompatibility::parse(&vec![b' '; MAX_DESCRIPTOR_BYTES + 1]).is_err()); + } + + #[test] + fn binding_requires_exact_schema_profile_format_and_artifact_pins() { + let (descriptor, mut manifest, root_sha, artifacts, _dir) = fixture(); + bind(&descriptor, &manifest, &root_sha, &artifacts).expect("valid exact binding"); + assert!( + bind( + &descriptor, + &manifest, + &Sha256Hex::from_bytes(b"other"), + &artifacts + ) + .is_err() + ); + let mut tampered = artifacts.clone(); + tampered[0].sha256 = Sha256Hex::from_bytes(b"tampered"); + assert!(bind(&descriptor, &manifest, &root_sha, &tampered).is_err()); + let tar_index = manifest + .members + .iter() + .position(|member| { + member.role == ImageSetRole::WorkloadRootfs(WorkloadImageProfile::DefaultTenant) + && member.target == MemberTarget::Arch(GuestArch::X86_64) + }) + .expect("rootfs"); + manifest.members[tar_index] + .artifacts + .iter_mut() + .find(|artifact| artifact.name == descriptor.composition_tar.name) + .expect("tar") + .format = ArtifactFormat::Ext4; + assert!(bind(&descriptor, &manifest, &root_sha, &artifacts).is_err()); + manifest.members[tar_index] + .artifacts + .iter_mut() + .find(|artifact| artifact.name == descriptor.composition_tar.name) + .expect("tar") + .format = ArtifactFormat::TarGz; + manifest.schema_version = super::super::LEGACY_IMAGE_SET_SCHEMA_VERSION; + manifest.compatibility.base_image_contract = None; + assert!(bind(&descriptor, &manifest, &root_sha, &artifacts).is_err()); + } + + fn test_set_check( + request: &ImageSetVerification<'_>, + names: &[ArtifactName], + ) -> Result { + super::super::verify::verify_checked_selected(request, names, |_, _, _| Ok(())) + } + + fn test_signature(bytes: &[u8], bundle: &[u8], _: &KeylessTrust) -> Result<(), String> { + if bundle == Sha256Hex::from_bytes(bytes).as_str().as_bytes() { + Ok(()) + } else { + Err("signature does not bind descriptor bytes".into()) + } + } + + fn test_feed(revoked: Option) -> VerifiedImageSetRevocations { + use crate::crypto::image_verify::VerifiedSigner; + use crate::pack_revocation::{PACK_REVOCATION_SCHEMA_VERSION, PackRevocationList}; + use crate::pack_trust::RevokedPack; + use crate::release_trust::{RELEASE_OIDC_ISSUER, image_set_revocation_identity}; + use chrono::{TimeZone, Utc}; + + let issued_at = Utc + .with_ymd_and_hms(2026, 11, 1, 0, 0, 0) + .single() + .expect("date"); + let mut list = PackRevocationList { + schema_version: PACK_REVOCATION_SCHEMA_VERSION, + revocations: Vec::new(), + issued_at, + not_after: issued_at + chrono::Duration::days(20), + }; + if let Some(sha) = revoked { + let manifest = super::super::tests::manifest(); + let lock = super::super::tests::lock_for(&manifest); + list.revocations.push(RevokedPack { + key_id: crate::plan::bundle::key_id_from_identity( + &lock.signing_identity.certificate_identity(&lock.repository), + ), + pack_hash: Some(sha), + reason: "withdrawn descriptor".into(), + }); + } + crate::image_set_revocation::verify_image_set_revocations_with( + &serde_json::to_vec(&list).expect("list"), + b"test", + issued_at + chrono::Duration::days(1), + None, + |_, _| { + Ok(VerifiedSigner { + identity: image_set_revocation_identity(1), + issuer: RELEASE_OIDC_ISSUER.into(), + }) + }, + ) + .expect("fresh authenticated test feed") + } + + #[test] + fn production_checks_distinct_signature_selected_bytes_and_revocation() { + let (descriptor, manifest, _, _, dir) = fixture(); + let manifest_bytes = serde_json::to_vec(&manifest).expect("manifest"); + let lock = super::super::tests::lock_for(&manifest); + let request = + ImageSetVerification::new(&manifest_bytes, b"set signature", &lock, dir.path()); + let bytes = serde_json::to_vec(&serde_json::json!({ + "schema": descriptor.schema, + "image_set_manifest_sha256": descriptor.image_set_manifest_sha256, + "arch": descriptor.arch, + "profile": descriptor.profile, + "kernel": {"name": descriptor.kernel.name, "sha256": descriptor.kernel.sha256, "size": descriptor.kernel.size}, + "composition_tar": {"name": descriptor.composition_tar.name, "sha256": descriptor.composition_tar.sha256, "size": descriptor.composition_tar.size}, + "base_image_contract": 1, "boot_protocol": "linux_direct", "filesystem_abi": 1, + "kernel_version": descriptor.kernel_version, + "libc_loaders": [{"libc":"musl","path":"/lib/ld-musl-x86_64.so.1"}], + "kernel_capabilities": ["virtio_vsock", "virtio_blk"] + })) + .expect("descriptor"); + let signature = Sha256Hex::from_bytes(&bytes); + let check = |feed: &VerifiedImageSetRevocations, bytes: &[u8], signature: &[u8]| { + verify_production_with( + bytes, + signature, + &request, + GuestArch::X86_64, + WorkloadImageProfile::DefaultTenant, + feed, + Checks { + signature: test_signature, + image_set: test_set_check, + }, + ) + }; + let good = test_feed(None); + let held = check(&good, &bytes, signature.as_str().as_bytes()) + .expect("test signer and set accepted"); + assert_eq!(held.descriptor_bytes(), bytes); + assert_eq!(held.signature_bundle(), signature.as_str().as_bytes()); + let checks = || Checks { + signature: test_signature, + image_set: test_set_check, + }; + let fresh = held + .reverify_with(&request, || Ok(test_feed(None)), checks()) + .expect("held evidence reverifies with a current feed"); + assert_eq!(fresh.artifacts(), held.artifacts()); + assert!(matches!( + held.reverify_with( + &request, + || Err(ImageSetRevocationStoreError::Missing), + checks() + ), + Err(BaseCompatibilityError::Revocations( + ImageSetRevocationStoreError::Missing + )) + )); + assert!(matches!( + held.reverify_with( + &request, + || Err(ImageSetRevocationStoreError::Verification( + crate::image_set_revocation::ImageSetRevocationError::Expired { + not_after: chrono::Utc::now() - chrono::Duration::seconds(1), + now: chrono::Utc::now(), + } + )), + checks() + ), + Err(BaseCompatibilityError::Revocations( + ImageSetRevocationStoreError::Verification( + crate::image_set_revocation::ImageSetRevocationError::Expired { .. } + ) + )) + )); + assert!(matches!( + held.reverify_with( + &request, + || Ok(test_feed(Some(Sha256Hex::from_bytes(&bytes)))), + checks() + ), + Err(BaseCompatibilityError::Revoked(_)) + )); + let mut changed_bundle = held.signature_bundle.clone(); + changed_bundle.push(b'!'); + let mut changed_evidence = held; + changed_evidence.signature_bundle = changed_bundle; + assert!(matches!( + changed_evidence.reverify_with(&request, || Ok(test_feed(None)), checks()), + Err(BaseCompatibilityError::Signature(_)) + )); + changed_evidence.signature_bundle = signature.as_str().as_bytes().to_vec(); + changed_evidence.descriptor_bytes.push(b' '); + assert!(matches!( + changed_evidence.reverify_with(&request, || Ok(test_feed(None)), checks()), + Err(BaseCompatibilityError::Signature(_)) + )); + let changed_set = ImageSetVerification::new(b"{}", b"set signature", &lock, dir.path()); + assert!(matches!( + fresh.reverify_with(&changed_set, || Ok(test_feed(None)), checks()), + Err(BaseCompatibilityError::ImageSet(_)) + )); + assert!(matches!( + check(&good, &bytes, b"wrong"), + Err(BaseCompatibilityError::Signature(_)) + )); + let mut altered = bytes.clone(); + let version_at = altered + .windows(b"6.12.3".len()) + .position(|window| window == b"6.12.3") + .expect("version in descriptor"); + altered[version_at + 5] = b'4'; + assert!(matches!( + check(&good, &altered, signature.as_str().as_bytes()), + Err(BaseCompatibilityError::Signature(_)) + )); + let revoked = test_feed(Some(Sha256Hex::from_bytes(&bytes))); + assert!(matches!( + check(&revoked, &bytes, signature.as_str().as_bytes()), + Err(BaseCompatibilityError::Revoked(_)) + )); + std::fs::write(dir.path().join("kernel.elf"), b"tampered kernel").expect("tamper"); + assert!(matches!( + fresh.reverify_with(&request, || Ok(test_feed(None)), checks()), + Err(BaseCompatibilityError::ImageSet(_)) + )); + assert!(matches!( + check(&good, &bytes, signature.as_str().as_bytes()), + Err(BaseCompatibilityError::ImageSet(_)) + )); + } + + #[cfg(feature = "host-state")] + #[test] + fn component_requirements_compare_numeric_kernel_loader_abi_and_capabilities() { + let (mut descriptor, _, _, artifacts, _dir) = fixture(); + descriptor.kernel_version = "6.10.0".into(); + let verified = ProductionBaseCompatibility { + descriptor, + descriptor_bytes: Vec::new(), + signature_bundle: Vec::new(), + kernel: artifacts[0].clone(), + composition_tar: artifacts[1].clone(), + }; + let mut requirements = ComponentRequirements { + filesystem_abi: 1, + libc: "musl".into(), + loader: Some("/lib/ld-musl-x86_64.so.1".into()), + minimum_kernel: "6.9.0".into(), + required_capabilities: vec!["virtio_vsock".into()], + }; + let check = |requirements: &ComponentRequirements, arch| { + verified.check_component_requirements(requirements, arch) + }; + check(&requirements, GuestArch::X86_64).expect("6.10 >= 6.9"); + requirements.libc = "static".into(); + requirements.loader = None; + check(&requirements, GuestArch::X86_64).expect("static requires no loader"); + requirements.loader = Some("/lib/ld-musl-x86_64.so.1".into()); + assert!(check(&requirements, GuestArch::X86_64).is_err()); + requirements.libc = "glibc".into(); + assert!(check(&requirements, GuestArch::X86_64).is_err()); + requirements.libc = "musl".into(); + requirements.loader = Some("/lib/not-the-loader".into()); + assert!(check(&requirements, GuestArch::X86_64).is_err()); + requirements.loader = Some("/lib/ld-musl-x86_64.so.1".into()); + requirements.minimum_kernel = "6.11.0".into(); + assert!(check(&requirements, GuestArch::X86_64).is_err()); + requirements.minimum_kernel = "6.10.0-rc1".into(); + assert!(check(&requirements, GuestArch::X86_64).is_err()); + requirements.minimum_kernel = "6.9.0".into(); + requirements.required_capabilities = vec!["dm_verity".into()]; + assert!(check(&requirements, GuestArch::X86_64).is_err()); + requirements.required_capabilities = vec!["guest_nic".into()]; + assert!(check(&requirements, GuestArch::X86_64).is_err()); + requirements.required_capabilities.clear(); + requirements.filesystem_abi = 2; + assert!(check(&requirements, GuestArch::X86_64).is_err()); + requirements.filesystem_abi = 1; + assert!(check(&requirements, GuestArch::Aarch64).is_err()); + } + + #[test] + fn unsigned_local_candidate_stays_development_and_checks_measured_bytes() { + let (descriptor, mut manifest, _, _, dir) = fixture(); + let current: super::super::LocalCheckouts = serde_json::from_value(serde_json::json!({ + "images": {"commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "worktree": {"state": "clean"}} + })) + .expect("local checkout identity"); + manifest.producer = super::super::ImageSetProducer::LocalCheckouts(current.clone()); + manifest.revocation_channel = None; + manifest.members.retain(|member| { + member.target == MemberTarget::Arch(GuestArch::X86_64) + && matches!( + member.role, + ImageSetRole::WorkloadKernel(WorkloadImageProfile::DefaultTenant) + | ImageSetRole::WorkloadRootfs(WorkloadImageProfile::DefaultTenant) + ) + }); + for member in &mut manifest.members { + member.pack_hash = None; + member.sbom = None; + for artifact in &mut member.artifacts { + if artifact.name != descriptor.kernel.name + && artifact.name != descriptor.composition_tar.name + { + let bytes = artifact.name.as_str().as_bytes(); + std::fs::write(dir.path().join(artifact.name.as_str()), bytes) + .expect("local artifact"); + artifact.sha256 = Sha256Hex::from_bytes(bytes); + artifact.size = bytes.len() as u64; + } + } + } + let manifest_bytes = serde_json::to_vec(&manifest).expect("manifest"); + let mut descriptor_value = serde_json::json!({ + "schema": "base-compatibility/v1", + "image_set_manifest_sha256": Sha256Hex::from_bytes(&manifest_bytes), + "arch": "x86_64", "profile": "default_tenant", + "kernel": {"name": descriptor.kernel.name, "sha256": descriptor.kernel.sha256, "size": descriptor.kernel.size}, + "composition_tar": {"name": descriptor.composition_tar.name, "sha256": descriptor.composition_tar.sha256, "size": descriptor.composition_tar.size}, + "base_image_contract": 1, "boot_protocol": "linux_direct", "filesystem_abi": 1, + "kernel_version": "6.12.3", + "libc_loaders": [{"libc": "musl", "path": "/lib/ld-musl-x86_64.so.1"}], + "kernel_capabilities": ["virtio_vsock"] + }); + let bytes = serde_json::to_vec(&descriptor_value).expect("descriptor"); + let request = LocalImageSetVerification::new( + &manifest_bytes, + dir.path(), + ¤t, + GuestArch::X86_64, + ); + let verified = verify_development_base_compatibility( + &bytes, + &request, + GuestArch::X86_64, + WorkloadImageProfile::DefaultTenant, + ) + .expect("unsigned local descriptor remains development"); + assert_eq!(verified.descriptor_bytes(), bytes); + assert_eq!(verified.artifacts().0.name, descriptor.kernel.name); + verified.reverify(&request).expect("unchanged local source"); + let changed_checkout: super::super::LocalCheckouts = + serde_json::from_value(serde_json::json!({ + "images": {"commit": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "worktree": {"state": "clean"}} + })) + .expect("changed checkout"); + let stale_request = LocalImageSetVerification::new( + &manifest_bytes, + dir.path(), + &changed_checkout, + GuestArch::X86_64, + ); + assert!(matches!( + verified.reverify(&stale_request), + Err(BaseCompatibilityError::ImageSet(_)) + )); + descriptor_value["image_set_manifest_sha256"] = + serde_json::json!(Sha256Hex::from_bytes(b"other")); + assert!( + verify_development_base_compatibility( + &serde_json::to_vec(&descriptor_value).expect("changed descriptor"), + &request, + GuestArch::X86_64, + WorkloadImageProfile::DefaultTenant, + ) + .is_err() + ); + std::fs::write(dir.path().join("base.tar.gz"), b"changed").expect("tamper"); + assert!(matches!( + verified.reverify(&request), + Err(BaseCompatibilityError::ImageSet(_)) + )); + assert!( + verify_development_base_compatibility( + &bytes, + &request, + GuestArch::X86_64, + WorkloadImageProfile::DefaultTenant, + ) + .is_err() + ); + } +} diff --git a/crates/mvm-core/src/image_set/tests.rs b/crates/mvm-core/src/image_set/tests.rs index 4fb84e6537..4c53df6a23 100644 --- a/crates/mvm-core/src/image_set/tests.rs +++ b/crates/mvm-core/src/image_set/tests.rs @@ -442,7 +442,7 @@ fn manifest_at(set_version: &str) -> ImageSetManifest { } } -fn manifest() -> ImageSetManifest { +pub(super) fn manifest() -> ImageSetManifest { manifest_at("1.0.0") } @@ -557,7 +557,7 @@ fn digest(manifest: &ImageSetManifest) -> Sha256Hex { Sha256Hex::from_bytes(&serde_json::to_vec(manifest).unwrap()) } -fn lock_for(manifest: &ImageSetManifest) -> ImageLock { +pub(super) fn lock_for(manifest: &ImageSetManifest) -> ImageLock { let release_tag = release(manifest).release_tag.clone(); ImageLock { schema_version: IMAGE_LOCK_SCHEMA_VERSION, diff --git a/crates/mvm-core/src/image_set/verify.rs b/crates/mvm-core/src/image_set/verify.rs index be5e1d7ee5..cd7f167916 100644 --- a/crates/mvm-core/src/image_set/verify.rs +++ b/crates/mvm-core/src/image_set/verify.rs @@ -121,6 +121,25 @@ impl<'a> ImageSetVerification<'a> { self.revocations = Some(revocations); self } + + pub(super) fn lock_trust(&self) -> KeylessTrust { + self.lock.keyless_trust() + } + + pub(super) fn with_current_revocations<'b>( + &'b self, + revocations: &'b dyn PackRevocationChecker, + ) -> ImageSetVerification<'b> { + ImageSetVerification { + manifest_bytes: self.manifest_bytes, + signature_bundle: self.signature_bundle, + lock: self.lock, + artifact_dir: self.artifact_dir, + requirement: self.requirement, + host_protocols: self.host_protocols, + revocations: Some(revocations), + } + } } /// What a verification established, for a caller that now wants to use the set. diff --git a/crates/mvm-core/src/plan/bundle.rs b/crates/mvm-core/src/plan/bundle.rs index 14bf30e285..0c05163e4a 100644 --- a/crates/mvm-core/src/plan/bundle.rs +++ b/crates/mvm-core/src/plan/bundle.rs @@ -66,8 +66,6 @@ use std::collections::BTreeMap; #[cfg(feature = "host-state")] -use std::collections::BTreeSet; -#[cfg(feature = "host-state")] use std::io::{Cursor, Read, Write}; use std::path::{Path, PathBuf}; @@ -85,9 +83,10 @@ pub use mvm_contract::plan::bundle::{ BUNDLE_SCHEMA_VERSION, BundleArtifact, BundleManifest, BundleMember, BundleResources, BundleSecurityPosture, CANONICAL_LINUX_PROOF_MIN_SCHEMA, CLI_BOOT_ASSETS_MEMBER_MIN_SCHEMA, CanonicalLinuxProof, ENVIRONMENT_MEMBER_MIN_SCHEMA, EmbeddedCliBootAssets, KeyId, - LOCAL_COMPOSITION_MIN_SCHEMA, LocalComposition, LocalCompositionLayer, MANIFEST_FILENAME, - MAX_BUNDLE_ENTRY_BYTES, MAX_BUNDLE_TOTAL_BYTES, MAX_KERNEL_CMDLINE_BYTES, PlanArtifact, - SIGNATURE_FILENAME, VerityInfo, signature_from_base64, signature_to_base64, + LOCAL_COMPOSITION_MIN_SCHEMA, LOCAL_DEVELOPMENT_BASE_PROOF_MIN_SCHEMA, LocalComposition, + LocalCompositionLayer, LocalDevelopmentBaseProof, MANIFEST_FILENAME, MAX_BUNDLE_ENTRY_BYTES, + MAX_BUNDLE_TOTAL_BYTES, MAX_KERNEL_CMDLINE_BYTES, PlanArtifact, SIGNATURE_FILENAME, VerityInfo, + signature_from_base64, signature_to_base64, }; pub use mvm_contract::plan::environment::{ ApplicationService, ENVIRONMENT_DESCRIPTOR_VERSION, EnvironmentCapability, @@ -97,14 +96,17 @@ pub use mvm_contract::plan::environment::{ use crate::arch::GuestArch; use crate::image_set::{ - BackendImageSupport, HostProtocolSupport, ImageSetError, ImageSetManifest, ImageSetRequirement, - ImageSetRole, RetiredGuestRuntimeRole, WorkloadImageProfile, + ArtifactFormat, BackendImageSupport, HostProtocolSupport, ImageSetError, ImageSetManifest, + ImageSetRequirement, ImageSetRole, RetiredGuestRuntimeRole, WorkloadImageProfile, check_workload_protocol_compatibility, require_complete, select_member, select_workload_image, validate_structure, }; use crate::packs::Sha256Hex; mod declarations; +#[cfg(feature = "host-state")] +mod embedded_image_set; +mod local_development_base; mod lookup; mod signer; mod stream; @@ -114,6 +116,9 @@ pub use declarations::{ unbound_environment_secret_slots, unsupported_environment_requirements, }; #[cfg(feature = "host-state")] +use embedded_image_set::{verify_boot_assets, verify_embedded_image_sets}; +pub use local_development_base::{select_local_development_base, select_local_development_source}; +#[cfg(feature = "host-state")] pub use lookup::existing_local_build_key; pub use lookup::{ BundleResolveError, BundleResolver, FsBundleResolver, FsTrustStore, LocalBundleTrustStore, @@ -770,6 +775,14 @@ pub fn verify_plan_bundle( { return Err(PlanBundleError::CanonicalKernelCustodyRequired); } + if verified + .manifest + .members + .iter() + .any(|member| matches!(member, BundleMember::LocalDevelopmentBaseProof(_))) + { + return Err(PlanBundleError::LocalDevelopmentCustodyRequired); + } Ok(()) }) } @@ -794,6 +807,119 @@ pub fn verify_canonical_plan_bundle( }) } +/// Verify ordinary dev bundles, or a local base with same-host source and kernel custody. +/// Unlike [`verify_canonical_plan_bundle`], this never accepts canonical Linux proof. +#[cfg(feature = "host-state")] +pub fn verify_development_plan_bundle( + pin: &PlanArtifact, + resolver: &dyn BundleResolver, + trust: &dyn TrustStore, + host_key_id: &KeyId, + config: &crate::vm_backend::VmStartConfig, +) -> Result { + verify_plan_bundle_checked(pin, resolver, trust, |verified| { + if verified + .manifest + .members + .iter() + .any(|member| matches!(member, BundleMember::CanonicalLinuxProof(_))) + { + return Err(PlanBundleError::CanonicalKernelCustodyRequired); + } + let Some(proof) = verified + .manifest + .members + .iter() + .find_map(|member| match member { + BundleMember::LocalDevelopmentBaseProof(proof) => Some(proof), + _ => None, + }) + else { + return Ok(()); + }; + let refused = |reason: &str| PlanBundleError::LocalBaseBinding(reason.into()); + if verified.key_id != *host_key_id + || verified + .manifest + .labels + .get(BUNDLE_ORIGIN_LABEL) + .map(String::as_str) + != Some(LOCAL_BUILD_ORIGIN) + || config.canonical_kernel.is_some() + { + return Err(refused( + "local base must be signed on this host without canonical authority", + )); + } + let kernel = verified + .manifest + .find_by_role(&ArtifactRole::Kernel) + .ok_or_else(|| refused("local kernel declaration missing"))?; + if config.kernel_read_sha256.as_deref() != Some(&kernel.sha256) + || config.kernel_path.is_none() + { + return Err(refused("local kernel custody differs from selected base")); + } + let actual = crate::crypto::image_verify::sha256_file(Path::new( + config.kernel_path.as_deref().expect("checked above"), + )) + .map_err(|_| refused("local selected kernel is unreadable"))?; + if actual != kernel.sha256 { + return Err(refused("local selected kernel bytes differ")); + } + verify_local_development_source(proof) + .map_err(|_| refused("local composition source differs from signed evidence"))?; + Ok(()) + }) +} + +#[cfg(feature = "host-state")] +pub fn verify_local_development_source(proof: &LocalDevelopmentBaseProof) -> std::io::Result<()> { + use std::io::Read; + let path = Path::new(&proof.composition_tar_path); + if std::fs::canonicalize(path)? != path { + return Err(std::io::Error::other( + "local composition source path changed", + )); + } + let metadata = std::fs::symlink_metadata(path)?; + if !metadata.is_file() || metadata.len() != proof.composition_tar_size { + return Err(std::io::Error::other("local composition source changed")); + } + let mut options = std::fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK); + } + let mut file = options.open(path)?; + let held = file.metadata()?; + if !held.is_file() || held.len() != proof.composition_tar_size { + return Err(std::io::Error::other("local composition source changed")); + } + let mut digest = Sha256::new(); + let mut bounded = (&mut file).take(proof.composition_tar_size + 1); + let mut bytes = [0u8; 64 * 1024]; + let mut size = 0u64; + loop { + let count = bounded.read(&mut bytes)?; + if count == 0 { + break; + } + size += count as u64; + digest.update(&bytes[..count]); + } + if size != proof.composition_tar_size + || hex::encode(digest.finalize()) != proof.composition_tar_sha256 + { + return Err(std::io::Error::other( + "local composition source digest changed", + )); + } + Ok(()) +} + /// The canonical owner must have been prepared from this very bundle, and the /// bundle must declare the canonical proof the owner was prepared from. #[cfg(feature = "host-state")] @@ -875,6 +1001,10 @@ fn verify_plan_bundle_checked( pub enum PlanBundleError { #[error("canonical kernel binding refused: {0}")] CanonicalKernelBinding(String), + #[error("local development base binding refused: {0}")] + LocalBaseBinding(String), + #[error("local development base requires same-host development admission")] + LocalDevelopmentCustodyRequired, #[error( "canonical Linux proof requires retained backend kernel custody; this admission path does not yet support it" )] @@ -987,6 +1117,8 @@ pub struct VerifiedBootAssets { pub arch: GuestArch, pub image_set: VerifiedEmbeddedImageSet, pub cli_runtime: Option, + /// Local source evidence is never interchangeable with a signed Linux root. + pub local_development_base: Option, } /// The image-set role of the runtime overlay a bundle's boot assets carry. @@ -1062,6 +1194,47 @@ pub fn check_boot_assets_for_backend( ) -> Result<(), ImageSetError> { check_workload_protocol_compatibility(&boot.image_set.manifest.compatibility, host_protocols)?; if boot.cli_runtime.is_some() { + if let Some(proof) = boot.local_development_base.as_ref() { + let mut selected = boot.image_set.manifest.clone(); + let kernel_name = boot.image_set.artifact_paths.keys().next().ok_or( + ImageSetError::MemberNotFound { + role: ImageSetRole::WorkloadKernel(WorkloadImageProfile::DefaultTenant), + }, + )?; + let declaration = boot + .image_set + .manifest + .members + .iter() + .flat_map(|member| &member.artifacts) + .find(|artifact| artifact.name.as_str() == kernel_name) + .ok_or(ImageSetError::MemberNotFound { + role: ImageSetRole::WorkloadKernel(WorkloadImageProfile::DefaultTenant), + })?; + let (selected_kernel, selected_rootfs) = select_local_development_base( + &boot.image_set.manifest, + boot.arch, + declaration.sha256.as_str(), + declaration.size, + proof, + ) + .map_err(|_| ImageSetError::MemberNotFound { + role: ImageSetRole::WorkloadKernel(WorkloadImageProfile::DefaultTenant), + })?; + let profile = match selected_kernel.role { + ImageSetRole::WorkloadKernel(profile) => profile, + _ => unreachable!("local base selection returns a workload kernel"), + }; + selected.members.retain(|member| { + !matches!( + member.role, + ImageSetRole::WorkloadKernel(_) | ImageSetRole::WorkloadRootfs(_) + ) || member == selected_kernel + || member == selected_rootfs + }); + select_workload_image(&selected, profile, boot.arch, backend)?; + return Ok(()); + } select_member( &boot.image_set.manifest, ImageSetRole::WorkloadKernel(WorkloadImageProfile::DefaultTenant), @@ -1276,197 +1449,23 @@ fn verify_signed_manifest( }); } declarations::validate_declarations(&manifest)?; - - Ok((manifest, declared_key_id)) -} - -/// Bind each embedded image-set declaration to the already verified bundle -/// artifacts. The image-set module owns structure, completeness and backend -/// compatibility; this adapter owns only the cross-manifest name/hash binding. -#[cfg(feature = "host-state")] -fn verify_embedded_image_sets( - bundle: &BundleManifest, - artifacts: &dyn stream::ArtifactSource, -) -> Result, BundleVerifyError> { - let mut names = BTreeSet::new(); - for artifact in &bundle.artifacts { - if !names.insert(artifact.name.as_str()) { - return Err(BundleVerifyError::DuplicateArtifactName { - name: artifact.name.clone(), - }); - } - } - - bundle - .members - .iter() - .filter_map(|member| match member { - BundleMember::EmbeddedImageSet { manifest_artifact } => Some(manifest_artifact), - BundleMember::KernelCmdline { .. } - | BundleMember::EmbeddedBootAssets { .. } - | BundleMember::EmbeddedCliBootAssets(_) - | BundleMember::CanonicalLinuxProof(_) - | BundleMember::LocalComposition(_) - | BundleMember::SecurityPosture(_) - | BundleMember::BuildProvenance(_) - | BundleMember::Environment(_) => None, - }) - .map(|manifest_artifact| { - verify_embedded_image_set( - bundle, - artifacts, - manifest_artifact, - ImageSetSelection::Full, - ) - }) - .collect() -} - -#[cfg(feature = "host-state")] -fn verify_boot_assets( - bundle: &BundleManifest, - artifacts: &dyn stream::ArtifactSource, -) -> Result, BundleVerifyError> { - let Some((manifest_artifact, cli_runtime)) = - bundle.members.iter().find_map(|member| match member { - BundleMember::EmbeddedBootAssets { manifest_artifact } => { - Some((manifest_artifact, None)) - } - BundleMember::EmbeddedCliBootAssets(runtime) => { - Some((&runtime.manifest_artifact, Some(runtime))) - } - BundleMember::EmbeddedImageSet { .. } - | BundleMember::CanonicalLinuxProof(_) - | BundleMember::LocalComposition(_) - | BundleMember::KernelCmdline { .. } - | BundleMember::SecurityPosture(_) - | BundleMember::BuildProvenance(_) - | BundleMember::Environment(_) => None, - }) - else { - return Ok(None); - }; - let arch = match bundle.arch.as_str() { - "x86_64" => GuestArch::X86_64, - "aarch64" => GuestArch::Aarch64, - other => { - return Err(BundleVerifyError::ManifestParse(format!( - "unknown boot-assets architecture {other}" - ))); - } - }; - let selection = if cli_runtime.is_some() { - ImageSetSelection::CliKernel(arch) - } else { - ImageSetSelection::LegacyRuntime(arch) - }; - let image_set = verify_embedded_image_set(bundle, artifacts, manifest_artifact, selection)?; - Ok(Some(VerifiedBootAssets { - arch, - image_set, - cli_runtime: cli_runtime.cloned(), - })) -} - -#[derive(Clone, Copy)] -#[cfg(feature = "host-state")] -enum ImageSetSelection { - Full, - LegacyRuntime(GuestArch), - CliKernel(GuestArch), -} - -#[cfg(feature = "host-state")] -fn verify_embedded_image_set( - bundle: &BundleManifest, - artifacts: &dyn stream::ArtifactSource, - manifest_artifact: &str, - selection: ImageSetSelection, -) -> Result { - let declaration = bundle - .artifacts - .iter() - .find(|artifact| artifact.name == manifest_artifact) - .ok_or_else(|| BundleVerifyError::ImageSetManifestArtifactMissing { - name: manifest_artifact.to_string(), - })?; - let manifest_bytes = artifacts - .read_image_set_manifest(&declaration.path) - .map_err(|reason| BundleVerifyError::ImageSetManifestParse { - name: manifest_artifact.to_string(), - reason, - })?; - let manifest: ImageSetManifest = serde_json::from_slice(&manifest_bytes).map_err(|error| { - BundleVerifyError::ImageSetManifestParse { - name: manifest_artifact.to_string(), - reason: error.to_string(), - } - })?; - validate_structure(&manifest).map_err(|reason| BundleVerifyError::ImageSetRefused { - name: manifest_artifact.to_string(), - reason, - })?; - require_complete(&manifest, &ImageSetRequirement::current_train()).map_err(|reason| { - BundleVerifyError::ImageSetRefused { - name: manifest_artifact.to_string(), - reason, - } - })?; - - let selected = match selection { - ImageSetSelection::Full => Ok(manifest.members.iter().collect()), - ImageSetSelection::LegacyRuntime(arch) => { - select_boot_asset_members(&manifest, arch).map(|members| members.to_vec()) - } - ImageSetSelection::CliKernel(arch) => { - select_cli_boot_kernel(&manifest, arch).map(|member| vec![member]) - } - }; - let members = selected.map_err(|reason| BundleVerifyError::ImageSetRefused { - name: manifest_artifact.to_string(), - reason, - })?; - let mut artifact_paths = BTreeMap::new(); - for image_artifact in members - .into_iter() - .flat_map(|member| member.artifacts.iter()) - { - let name = image_artifact.name.as_str(); - let bundle_artifact = bundle - .artifacts - .iter() - .find(|artifact| match selection { - ImageSetSelection::CliKernel(_) => artifact.role == ArtifactRole::Kernel, - ImageSetSelection::Full | ImageSetSelection::LegacyRuntime(_) => { - artifact.name == name - } - }) - .ok_or_else(|| BundleVerifyError::ImageSetArtifactMissing { - name: name.to_string(), - })?; - if image_artifact.sha256.as_str() != bundle_artifact.sha256 { - return Err(BundleVerifyError::ImageSetArtifactDigestMismatch { - name: name.to_string(), - image_set: image_artifact.sha256.as_str().to_string(), - bundle: bundle_artifact.sha256.clone(), - }); - } - if image_artifact.size != bundle_artifact.size_bytes { - return Err(BundleVerifyError::ImageSetArtifactSizeMismatch { - name: name.to_string(), - image_set: image_artifact.size, - bundle: bundle_artifact.size_bytes, - }); + #[cfg(feature = "pr-test-guest-runtime")] + if manifest.members.iter().any(|member| { + matches!(member, BundleMember::EmbeddedCliBootAssets(runtime) if runtime.pr_test_pull_request.is_some()) + }) { + let host_key = existing_local_build_key() + .map_err(|_| BundleVerifyError::ManifestParse("PR test host signer key is unreadable".into()))? + .ok_or_else(|| BundleVerifyError::ManifestParse("PR test host signer key is missing".into()))?; + if bundle_origin(&manifest, &key_id_from_pubkey(&host_key), trust_store) + != BundleOrigin::LocalBuild + { + return Err(BundleVerifyError::ManifestParse( + "PR test runtime requires this host's signed local build".into(), + )); } - artifact_paths.insert(name.to_string(), bundle_artifact.path.clone()); } - Ok(VerifiedEmbeddedImageSet { - manifest_artifact: manifest_artifact.to_string(), - manifest_sha256: Sha256Hex::from_bytes(&manifest_bytes), - manifest, - artifact_paths, - }) + Ok((manifest, declared_key_id)) } /// Convenience: SHA-256 of the entire archive bytes, encoded as @@ -1484,13 +1483,13 @@ mod tests { use chrono::{TimeZone, Utc}; use ed25519_dalek::{Signer, SigningKey}; use rand::Rng; - use std::collections::HashMap; + use std::collections::{BTreeSet, HashMap}; use crate::image_set::{ ArtifactFormat, ArtifactName, BootProtocol, GitCommit, GuestDeviceRequirement, ImageSetCompatibility, ImageSetMember, ImageSetProducer, ImageSetRole, ImageSetVersion, - MemberArtifact, MemberTarget, NixInputs, ProtocolRange, ReleaseProducer, ReleaseTag, - RepositorySlug, RevocationChannel, WorkflowPath, + LocalCheckouts, MemberArtifact, MemberTarget, NixInputs, ProtocolRange, ReleaseProducer, + ReleaseTag, RepoIdentity, RepositorySlug, RevocationChannel, WorkflowPath, WorktreeState, }; use crate::kernel_format::KernelFormat; use crate::packs::{FlakeLockIdentity, SbomReference, SourceRevisionIdentity}; @@ -1667,6 +1666,59 @@ mod tests { MapTrustStore(m) } + #[cfg(not(feature = "pr-test-guest-runtime"))] + #[test] + fn ordinary_bundle_reader_refuses_signed_pr_marker_but_keeps_old_schemas() { + let key = fresh_key(); + let directory = tempfile::tempdir().unwrap(); + let write_signed = |manifest: &BundleManifest, name: &str| { + let bytes = canonical_manifest_bytes(manifest).unwrap(); + let signature = key.sign(&bytes).to_bytes(); + let mut tar = tar::Builder::new(Vec::new()); + append_bytes(&mut tar, MANIFEST_FILENAME, &bytes).unwrap(); + append_bytes(&mut tar, SIGNATURE_FILENAME, &signature).unwrap(); + let path = directory.path().join(name); + std::fs::write(&path, tar.into_inner().unwrap()).unwrap(); + path + }; + let mut manifest = make_manifest(key_id_from_pubkey(&key.verifying_key()), Vec::new()); + for schema in [8, 9] { + manifest.schema_version = schema; + let archive = write_signed(&manifest, &format!("v{schema}.mvmpkg")); + verify_bundle_file(&archive, &trust(&key)) + .expect("older bundle schema without PR marker remains readable"); + } + manifest.schema_version = 10; + manifest.members.push( + serde_json::from_value(serde_json::json!({ + "class": "embedded_cli_boot_assets", + "manifest_artifact": "linux.json", + "version": "1.2.3", + "arch": "aarch64", + "archive_artifact": "runtime.tar.gz", + "checksum_artifact": "runtime.sha256", + "signature_bundle_artifact": "runtime.bundle", + "checksum_signature_bundle_artifact": "runtime.sha256.bundle", + "overlay_artifact": "overlay.ext4", + "overlay_verity_artifact": "overlay.verity", + "initramfs_artifact": "initrd", + "overlay_roothash": "a".repeat(64), + "protocol_version": 2, + "min_supported_protocol_version": 2, + "pr_test_pull_request": 42 + })) + .unwrap(), + ); + let archive = write_signed(&manifest, "pr-test.mvmpkg"); + let error = verify_bundle_file(&archive, &trust(&key)).unwrap_err(); + assert!( + error + .to_string() + .contains("explicitly compiled test-only verifier"), + "ordinary bundle reader must refuse before trusting any artifact: {error}" + ); + } + fn image_member_artifact(name: &str, format: ArtifactFormat) -> (MemberArtifact, Vec) { let bytes = format!("embedded bytes for {name}").into_bytes(); ( @@ -1843,6 +1895,177 @@ mod tests { ) } + #[test] + fn local_base_evidence_selects_only_the_exact_kernel_and_matching_tar_source() { + let (mut set, _) = complete_image_set(); + set.schema_version = crate::image_set::IMAGE_SET_SCHEMA_VERSION; + set.mvm_source_commit = None; + set.revocation_channel = None; + set.compatibility.guest_agent_protocol = None; + set.compatibility.base_image_contract = + Some(crate::image_set::BaseImageContract::LINUX_LAYER); + set.compatibility.builder_boot_abi = Some(crate::image_set::BuilderBootAbi::NO_MVM_BINARY); + let arch = GuestArch::Aarch64; + let profile = WorkloadImageProfile::DefaultTenant; + set.members.retain(|member| { + member.target.admits(arch) + && matches!( + member.role, + ImageSetRole::WorkloadKernel(found) | ImageSetRole::WorkloadRootfs(found) + if found == profile + ) + }); + for member in &mut set.members { + member.pack_hash = None; + member.sbom = None; + } + set.producer = ImageSetProducer::LocalCheckouts(LocalCheckouts { + images: RepoIdentity { + commit: GitCommit::new("a".repeat(40)).unwrap(), + worktree: WorktreeState::Clean, + }, + mvm: None, + }); + let kernel = set + .members + .iter() + .find(|member| { + member.role == ImageSetRole::WorkloadKernel(profile) && member.target.admits(arch) + }) + .unwrap() + .artifacts[0] + .clone(); + let rootfs = set + .members + .iter_mut() + .find(|member| { + member.role == ImageSetRole::WorkloadRootfs(profile) && member.target.admits(arch) + }) + .unwrap(); + let tar = MemberArtifact { + name: ArtifactName::new("base-composition.tar.gz").unwrap(), + format: ArtifactFormat::TarGz, + sha256: Sha256Hex::from_bytes(b"exact tar source"), + size: 16, + }; + rootfs.artifacts.push(tar.clone()); + let proof = LocalDevelopmentBaseProof { + manifest_artifact: "boot-image-set.json".into(), + composition_tar_sha256: tar.sha256.as_str().to_string(), + composition_tar_size: tar.size, + composition_tar_path: "/local/base-composition.tar.gz".into(), + }; + let pair = + select_local_development_base(&set, arch, kernel.sha256.as_str(), kernel.size, &proof) + .unwrap(); + assert_eq!(pair.0.artifacts[0], kernel); + assert!(pair.1.artifacts.contains(&tar)); + let (selected_kernel, selected_tar) = + select_local_development_source(&set, arch, profile).unwrap(); + assert_eq!(selected_kernel, &kernel); + assert_eq!(selected_tar, &tar); + let mut missing = set.clone(); + missing + .members + .iter_mut() + .find(|member| member.role == ImageSetRole::WorkloadRootfs(profile)) + .unwrap() + .artifacts + .retain(|artifact| artifact.format != ArtifactFormat::TarGz); + assert!(select_local_development_source(&missing, arch, profile).is_err()); + let mut duplicate = set.clone(); + let mut second_tar = tar.clone(); + second_tar.name = ArtifactName::new("other.tar.gz").unwrap(); + duplicate + .members + .iter_mut() + .find(|member| member.role == ImageSetRole::WorkloadRootfs(profile)) + .unwrap() + .artifacts + .push(second_tar); + assert!(select_local_development_source(&duplicate, arch, profile).is_err()); + let mut incomplete = set.clone(); + incomplete + .members + .retain(|member| !matches!(member.role, ImageSetRole::WorkloadKernel(_))); + assert!(select_local_development_source(&incomplete, arch, profile).is_err()); + let mut altered = proof.clone(); + altered.composition_tar_sha256 = "0".repeat(64); + assert!( + select_local_development_base( + &set, + arch, + kernel.sha256.as_str(), + kernel.size, + &altered + ) + .is_err() + ); + assert!(select_local_development_base(&set, arch, "0", kernel.size, &proof).is_err()); + assert!( + select_local_development_base( + &set, + arch, + kernel.sha256.as_str(), + kernel.size + 1, + &proof + ) + .is_err() + ); + let mut relabelled = set.clone(); + relabelled.producer = complete_image_set().0.producer; + assert!( + select_local_development_base( + &relabelled, + arch, + kernel.sha256.as_str(), + kernel.size, + &proof + ) + .is_err() + ); + let rootfs = set + .members + .iter_mut() + .find(|member| { + member.role == ImageSetRole::WorkloadRootfs(profile) && member.target.admits(arch) + }) + .unwrap(); + rootfs.build_mode = Some(crate::image_set::MemberBuildMode::Dev); + assert!( + select_local_development_base(&set, arch, kernel.sha256.as_str(), kernel.size, &proof) + .is_err() + ); + } + + #[test] + fn local_base_source_requires_exact_regular_bytes_not_only_a_declared_digest() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("source.tar.gz"); + std::fs::write(&path, b"exact tar source").unwrap(); + let proof = LocalDevelopmentBaseProof { + manifest_artifact: "boot-image-set.json".into(), + composition_tar_sha256: sha256_hex(b"exact tar source"), + composition_tar_size: 16, + composition_tar_path: std::fs::canonicalize(&path) + .unwrap() + .to_str() + .unwrap() + .into(), + }; + verify_local_development_source(&proof).unwrap(); + std::fs::write(&path, b"tampered source!").unwrap(); + assert!(verify_local_development_source(&proof).is_err()); + std::fs::remove_file(&path).unwrap(); + assert!(verify_local_development_source(&proof).is_err()); + #[cfg(unix)] + { + std::fs::write(dir.path().join("actual"), b"exact tar source").unwrap(); + std::os::unix::fs::symlink(dir.path().join("actual"), &path).unwrap(); + assert!(verify_local_development_source(&proof).is_err()); + } + } + fn embedded_bundle( sk: &SigningKey, image_set: &ImageSetManifest, @@ -2087,6 +2310,7 @@ mod tests { overlay_roothash: "a".repeat(64), protocol_version: 2, min_supported_protocol_version: 2, + pr_test_pull_request: None, })]; let archive = write_bundle(&manifest, &sk, payload).unwrap(); diff --git a/crates/mvm-core/src/plan/bundle/declarations.rs b/crates/mvm-core/src/plan/bundle/declarations.rs index e613fe4785..2e1e25e604 100644 --- a/crates/mvm-core/src/plan/bundle/declarations.rs +++ b/crates/mvm-core/src/plan/bundle/declarations.rs @@ -101,6 +101,12 @@ pub(super) fn validate_declarations(manifest: &BundleManifest) -> Result<(), Bun }) { validate_canonical_linux_proof(proof, manifest)?; } + if let Some(proof) = manifest.members.iter().find_map(|member| match member { + BundleMember::LocalDevelopmentBaseProof(proof) => Some(proof), + _ => None, + }) { + validate_local_development_base_proof(proof, manifest)?; + } if let Some(composition) = manifest.members.iter().find_map(|member| match member { BundleMember::LocalComposition(composition) => Some(composition), _ => None, @@ -152,6 +158,7 @@ fn declaration_class(member: &BundleMember) -> Option<&'static str> { BundleMember::EmbeddedBootAssets { .. } => Some("embedded_boot_assets"), BundleMember::EmbeddedCliBootAssets(_) => Some("embedded_cli_boot_assets"), BundleMember::CanonicalLinuxProof(_) => Some("canonical_linux_proof"), + BundleMember::LocalDevelopmentBaseProof(_) => Some("local_development_base_proof"), BundleMember::LocalComposition(_) => Some("local_composition"), BundleMember::KernelCmdline { .. } => Some("kernel_cmdline"), BundleMember::SecurityPosture(_) => Some("security_posture"), @@ -167,7 +174,17 @@ fn validate_local_composition( ) -> Result<(), BundleVerifyError> { let refused = |reason: &str| BundleVerifyError::ManifestParse(reason.into()); let proof = manifest.members.iter().find_map(|member| match member { - BundleMember::CanonicalLinuxProof(proof) => Some(proof), + BundleMember::CanonicalLinuxProof(proof) => Some(( + proof.manifest_artifact.as_str(), + vec![ + &proof.signature_bundle_artifact, + &proof.revocation_document_artifact, + &proof.revocation_signature_bundle_artifact, + ], + )), + BundleMember::LocalDevelopmentBaseProof(proof) => { + Some((proof.manifest_artifact.as_str(), Vec::new())) + } _ => None, }); if manifest @@ -179,10 +196,11 @@ fn validate_local_composition( || composition.layers.len() > 64 { return Err(refused( - "local composition requires a verity-sealed local build with canonical Linux proof", + "local composition requires a verity-sealed local build with base proof", )); } - let proof = proof.ok_or_else(|| refused("local composition requires canonical Linux proof"))?; + let (base, proof_artifacts) = + proof.ok_or_else(|| refused("local composition requires base proof"))?; let mut names = std::collections::BTreeSet::new(); let layer_names = composition.layers.iter().flat_map(|layer| { [ @@ -195,17 +213,8 @@ fn validate_local_composition( if !names.insert(name) { return Err(refused("duplicate local composition evidence artifact")); } - if [ - &proof.manifest_artifact, - &proof.signature_bundle_artifact, - &proof.revocation_document_artifact, - &proof.revocation_signature_bundle_artifact, - ] - .contains(&name) - { - return Err(refused( - "composition evidence aliases a canonical Linux proof", - )); + if name == base || proof_artifacts.contains(&name) { + return Err(refused("composition evidence aliases a Linux base proof")); } let artifact = manifest .find_by_name(name) @@ -222,6 +231,75 @@ fn validate_local_composition( Ok(()) } +#[cfg(feature = "host-state")] +fn validate_local_development_base_proof( + proof: &mvm_contract::plan::bundle::LocalDevelopmentBaseProof, + manifest: &BundleManifest, +) -> Result<(), BundleVerifyError> { + let refused = |reason: &str| BundleVerifyError::ManifestParse(reason.into()); + let runtime = manifest + .members + .iter() + .find_map(|member| match member { + BundleMember::EmbeddedCliBootAssets(runtime) => Some(runtime), + _ => None, + }) + .ok_or_else(|| refused("local development base requires explicit CLI boot assets"))?; + let path = std::path::Path::new(&proof.composition_tar_path); + if manifest + .members + .iter() + .any(|member| matches!(member, BundleMember::CanonicalLinuxProof(_))) + || manifest + .labels + .get(super::BUNDLE_ORIGIN_LABEL) + .map(String::as_str) + != Some(super::LOCAL_BUILD_ORIGIN) + || proof.manifest_artifact != runtime.manifest_artifact + || proof.composition_tar_size == 0 + || proof.composition_tar_size > super::MAX_BUNDLE_ENTRY_BYTES + || proof.composition_tar_sha256.len() != 64 + || !proof + .composition_tar_sha256 + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + || proof.composition_tar_path.len() > 4096 + || !path.is_absolute() + || !path.components().all(|component| { + matches!( + component, + std::path::Component::RootDir | std::path::Component::Normal(_) + ) + }) + || !manifest + .members + .iter() + .any(|member| matches!(member, BundleMember::LocalComposition(_))) + { + return Err(refused( + "invalid local development base proof or mixed authority", + )); + } + let artifact = manifest + .find_by_name(&proof.manifest_artifact) + .ok_or_else(|| refused("local development base manifest artifact missing"))?; + if artifact.role != ArtifactRole::Other + || artifact.size_bytes == 0 + || artifact.size_bytes > 4 * 1024 * 1024 + || manifest + .artifacts + .iter() + .filter(|item| item.role == ArtifactRole::Rootfs && item.size_bytes > 0) + .count() + != 1 + { + return Err(refused( + "local development base has invalid manifest or rootfs", + )); + } + Ok(()) +} + #[cfg(feature = "host-state")] fn validate_canonical_linux_proof( proof: &mvm_contract::plan::bundle::CanonicalLinuxProof, @@ -293,6 +371,36 @@ fn validate_cli_runtime( manifest: &BundleManifest, ) -> Result<(), BundleVerifyError> { let refused = |reason: &str| BundleVerifyError::ManifestParse(reason.to_owned()); + if let Some(pull_request) = runtime.pr_test_pull_request { + #[cfg(not(feature = "pr-test-guest-runtime"))] + let _ = pull_request; + #[cfg(not(feature = "pr-test-guest-runtime"))] + return Err(refused( + "PR test runtime requires an explicitly compiled test-only verifier", + )); + #[cfg(feature = "pr-test-guest-runtime")] + if pull_request == 0 + || manifest.profile.as_deref() != Some("dev") + || manifest.security_posture().map(|posture| posture.profile) != Some(AgentProfile::Dev) + || manifest + .labels + .get(super::BUNDLE_ORIGIN_LABEL) + .map(String::as_str) + != Some(super::LOCAL_BUILD_ORIGIN) + || !manifest + .members + .iter() + .any(|member| matches!(member, BundleMember::LocalDevelopmentBaseProof(_))) + || manifest + .members + .iter() + .any(|member| matches!(member, BundleMember::CanonicalLinuxProof(_))) + { + return Err(refused( + "PR test runtime requires a host-local Dev base without release or canonical authority", + )); + } + } if runtime.arch != manifest.arch || !matches!(runtime.arch.as_str(), "x86_64" | "aarch64") || runtime.version.is_empty() @@ -674,6 +782,7 @@ mod tests { overlay_roothash: "a".repeat(64), protocol_version: 2, min_supported_protocol_version: 2, + pr_test_pull_request: None, }; let proof = CanonicalLinuxProof { manifest_artifact: "linux.json".into(), @@ -820,6 +929,212 @@ mod tests { manifest } + #[test] + fn local_base_requires_schema_nine_sealed_output_and_no_canonical_escalation() { + use mvm_contract::plan::bundle::{ + CanonicalLinuxProof, EmbeddedCliBootAssets, LocalComposition, LocalDevelopmentBaseProof, + }; + let runtime = EmbeddedCliBootAssets { + manifest_artifact: "linux.json".into(), + version: "0.27.0".into(), + arch: "x86_64".into(), + archive_artifact: "runtime.tar.gz".into(), + checksum_artifact: "runtime.sha256".into(), + signature_bundle_artifact: "runtime.bundle".into(), + checksum_signature_bundle_artifact: "runtime.sha256.bundle".into(), + overlay_artifact: "overlay".into(), + overlay_verity_artifact: "overlay.verity".into(), + initramfs_artifact: "initrd".into(), + overlay_roothash: "a".repeat(64), + protocol_version: 2, + min_supported_protocol_version: 2, + pr_test_pull_request: None, + }; + let proof = LocalDevelopmentBaseProof { + manifest_artifact: "linux.json".into(), + composition_tar_sha256: "a".repeat(64), + composition_tar_size: 16, + composition_tar_path: "/local/base.tar.gz".into(), + }; + let names = [ + "linux.json", + "runtime.tar.gz", + "runtime.sha256", + "runtime.bundle", + "runtime.sha256.bundle", + "overlay", + "overlay.verity", + "initrd", + "kernel", + "rootfs.ext4", + "composition-lock.json", + ]; + let artifacts = names + .into_iter() + .map(|name| { + artifact( + name, + match name { + "initrd" => ArtifactRole::Initrd, + "kernel" => ArtifactRole::Kernel, + "rootfs.ext4" => ArtifactRole::Rootfs, + _ => ArtifactRole::Other, + }, + &"a".repeat(64), + 16, + ) + }) + .collect(); + let mut valid = with_verity(manifest( + artifacts, + vec![ + BundleMember::EmbeddedCliBootAssets(runtime), + BundleMember::LocalDevelopmentBaseProof(proof), + BundleMember::LocalComposition(LocalComposition { + lock_artifact: "composition-lock.json".into(), + layers: Vec::new(), + }), + ], + )); + valid.labels.insert( + super::super::BUNDLE_ORIGIN_LABEL.into(), + super::super::LOCAL_BUILD_ORIGIN.into(), + ); + validate_declarations(&valid).unwrap(); + let mut pr_test = valid.clone(); + let BundleMember::EmbeddedCliBootAssets(runtime) = &mut pr_test.members[0] else { + unreachable!("test fixture begins with CLI runtime") + }; + runtime.pr_test_pull_request = Some(42); + pr_test.schema_version = 9; + assert!(validate_declarations(&pr_test).is_err()); + pr_test.schema_version = 8; + assert!(validate_declarations(&pr_test).is_err()); + pr_test.schema_version = 10; + #[cfg(not(feature = "pr-test-guest-runtime"))] + assert!(validate_declarations(&pr_test).is_err()); + #[cfg(feature = "pr-test-guest-runtime")] + { + assert!( + validate_declarations(&pr_test).is_err(), + "missing Dev posture must fail" + ); + pr_test.profile = Some("dev".into()); + pr_test + .members + .push(BundleMember::SecurityPosture(BundleSecurityPosture { + profile: AgentProfile::Dev, + ..sealed() + })); + validate_declarations(&pr_test).unwrap(); + { + use ed25519_dalek::{Signer, SigningKey, VerifyingKey}; + struct HostKey(VerifyingKey); + impl super::super::TrustStore for HostKey { + fn lookup(&self, _: &KeyId) -> Option { + Some(self.0) + } + } + let home = tempfile::tempdir().unwrap(); + let mut env = crate::util::test_env::TestEnv::new(); + env.isolate_mvm_home(home.path()); + let host = SigningKey::from_bytes(&[42; 32]); + let other = SigningKey::from_bytes(&[43; 32]); + let keys = crate::config::mvm_keys_dir(); + std::fs::create_dir_all(&keys).unwrap(); + std::fs::write( + keys.join("host-signer.pub"), + host.verifying_key().as_bytes(), + ) + .unwrap(); + let mut signed = pr_test.clone(); + signed.key_id = super::super::key_id_from_pubkey(&host.verifying_key()); + let bytes = super::super::canonical_manifest_bytes(&signed).unwrap(); + let signature = host.sign(&bytes).to_bytes(); + super::super::verify_signed_manifest( + &bytes, + &signature, + &HostKey(host.verifying_key()), + ) + .expect("this host's signed local Dev bundle may carry the PR test marker"); + std::fs::write( + keys.join("host-signer.pub"), + other.verifying_key().as_bytes(), + ) + .unwrap(); + assert!( + super::super::verify_signed_manifest( + &bytes, + &signature, + &HostKey(host.verifying_key()), + ) + .is_err(), + "a different host's key cannot satisfy local PR test provenance" + ); + } + let mut not_local = pr_test.clone(); + not_local.labels.clear(); + assert!(validate_declarations(¬_local).is_err()); + let mut missing_base = pr_test.clone(); + missing_base + .members + .retain(|member| !matches!(member, BundleMember::LocalDevelopmentBaseProof(_))); + assert!(validate_declarations(&missing_base).is_err()); + let mut zero = pr_test.clone(); + let BundleMember::EmbeddedCliBootAssets(runtime) = &mut zero.members[0] else { + unreachable!("test fixture begins with CLI runtime") + }; + runtime.pr_test_pull_request = Some(0); + assert!(validate_declarations(&zero).is_err()); + let mut prod = pr_test.clone(); + prod.profile = Some("prod".into()); + assert!(validate_declarations(&prod).is_err()); + let mut canonical = pr_test.clone(); + canonical + .members + .push(BundleMember::CanonicalLinuxProof(CanonicalLinuxProof { + manifest_artifact: "linux.json".into(), + signature_bundle_artifact: "linux.bundle".into(), + revocation_document_artifact: "revocations.json".into(), + revocation_signature_bundle_artifact: "revocations.bundle".into(), + })); + assert!(validate_declarations(&canonical).is_err()); + } + let mut bad = valid.clone(); + bad.schema_version = 8; + assert!(validate_declarations(&bad).is_err()); + bad = valid.clone(); + bad.labels.clear(); + assert!(validate_declarations(&bad).is_err()); + bad = valid.clone(); + bad.members + .push(BundleMember::CanonicalLinuxProof(CanonicalLinuxProof { + manifest_artifact: "linux.json".into(), + signature_bundle_artifact: "linux.bundle".into(), + revocation_document_artifact: "revocations.json".into(), + revocation_signature_bundle_artifact: "revocations.bundle".into(), + })); + assert!(validate_declarations(&bad).is_err()); + for path in ["relative/base.tar.gz", "/local/../base.tar.gz"] { + let mut bad = valid.clone(); + let BundleMember::LocalDevelopmentBaseProof(proof) = &mut bad.members[1] else { + unreachable!() + }; + proof.composition_tar_path = path.into(); + assert!(validate_declarations(&bad).is_err(), "{path}"); + } + let mut bad = valid.clone(); + let BundleMember::LocalDevelopmentBaseProof(proof) = &mut bad.members[1] else { + unreachable!() + }; + proof.composition_tar_sha256 = "G".repeat(64); + assert!(validate_declarations(&bad).is_err()); + bad = valid.clone(); + bad.artifacts + .retain(|artifact| artifact.role != ArtifactRole::Rootfs); + assert!(validate_declarations(&bad).is_err()); + } + #[test] fn budget_refuses_an_entry_over_two_gib_before_counting_it() { let mut budget = BundleSizeBudget::default(); diff --git a/crates/mvm-core/src/plan/bundle/embedded_image_set.rs b/crates/mvm-core/src/plan/bundle/embedded_image_set.rs new file mode 100644 index 0000000000..4341c456e0 --- /dev/null +++ b/crates/mvm-core/src/plan/bundle/embedded_image_set.rs @@ -0,0 +1,221 @@ +//! Bind embedded image-set declarations to verified bundle artifacts. + +use std::collections::{BTreeMap, BTreeSet}; + +use super::{ + ArtifactRole, BundleManifest, BundleMember, BundleVerifyError, GuestArch, ImageSetManifest, + ImageSetRequirement, LocalDevelopmentBaseProof, Sha256Hex, VerifiedBootAssets, + VerifiedEmbeddedImageSet, require_complete, select_boot_asset_members, select_cli_boot_kernel, + select_local_development_base, stream, validate_structure, +}; + +/// Bind each embedded image-set declaration to the already verified bundle +/// artifacts. The image-set module owns structure, completeness and backend +/// compatibility; this adapter owns only the cross-manifest name/hash binding. +pub(super) fn verify_embedded_image_sets( + bundle: &BundleManifest, + artifacts: &dyn stream::ArtifactSource, +) -> Result, BundleVerifyError> { + let mut names = BTreeSet::new(); + for artifact in &bundle.artifacts { + if !names.insert(artifact.name.as_str()) { + return Err(BundleVerifyError::DuplicateArtifactName { + name: artifact.name.clone(), + }); + } + } + + bundle + .members + .iter() + .filter_map(|member| match member { + BundleMember::EmbeddedImageSet { manifest_artifact } => Some(manifest_artifact), + BundleMember::KernelCmdline { .. } + | BundleMember::EmbeddedBootAssets { .. } + | BundleMember::EmbeddedCliBootAssets(_) + | BundleMember::CanonicalLinuxProof(_) + | BundleMember::LocalDevelopmentBaseProof(_) + | BundleMember::LocalComposition(_) + | BundleMember::SecurityPosture(_) + | BundleMember::BuildProvenance(_) + | BundleMember::Environment(_) => None, + }) + .map(|manifest_artifact| { + verify_embedded_image_set( + bundle, + artifacts, + manifest_artifact, + ImageSetSelection::Full, + ) + }) + .collect() +} + +pub(super) fn verify_boot_assets( + bundle: &BundleManifest, + artifacts: &dyn stream::ArtifactSource, +) -> Result, BundleVerifyError> { + let Some((manifest_artifact, cli_runtime)) = + bundle.members.iter().find_map(|member| match member { + BundleMember::EmbeddedBootAssets { manifest_artifact } => { + Some((manifest_artifact, None)) + } + BundleMember::EmbeddedCliBootAssets(runtime) => { + Some((&runtime.manifest_artifact, Some(runtime))) + } + BundleMember::EmbeddedImageSet { .. } + | BundleMember::CanonicalLinuxProof(_) + | BundleMember::LocalDevelopmentBaseProof(_) + | BundleMember::LocalComposition(_) + | BundleMember::KernelCmdline { .. } + | BundleMember::SecurityPosture(_) + | BundleMember::BuildProvenance(_) + | BundleMember::Environment(_) => None, + }) + else { + return Ok(None); + }; + let arch = match bundle.arch.as_str() { + "x86_64" => GuestArch::X86_64, + "aarch64" => GuestArch::Aarch64, + other => { + return Err(BundleVerifyError::ManifestParse(format!( + "unknown boot-assets architecture {other}" + ))); + } + }; + let local = bundle.members.iter().find_map(|member| match member { + BundleMember::LocalDevelopmentBaseProof(proof) => Some(proof), + _ => None, + }); + let selection = if let Some(proof) = local { + ImageSetSelection::LocalKernel(arch, proof) + } else if cli_runtime.is_some() { + ImageSetSelection::CliKernel(arch) + } else { + ImageSetSelection::LegacyRuntime(arch) + }; + let image_set = verify_embedded_image_set(bundle, artifacts, manifest_artifact, selection)?; + Ok(Some(VerifiedBootAssets { + arch, + image_set, + cli_runtime: cli_runtime.cloned(), + local_development_base: local.cloned(), + })) +} + +enum ImageSetSelection<'a> { + Full, + LegacyRuntime(GuestArch), + CliKernel(GuestArch), + LocalKernel(GuestArch, &'a LocalDevelopmentBaseProof), +} + +fn verify_embedded_image_set( + bundle: &BundleManifest, + artifacts: &dyn stream::ArtifactSource, + manifest_artifact: &str, + selection: ImageSetSelection<'_>, +) -> Result { + let declaration = bundle + .artifacts + .iter() + .find(|artifact| artifact.name == manifest_artifact) + .ok_or_else(|| BundleVerifyError::ImageSetManifestArtifactMissing { + name: manifest_artifact.to_string(), + })?; + let manifest_bytes = artifacts + .read_image_set_manifest(&declaration.path) + .map_err(|reason| BundleVerifyError::ImageSetManifestParse { + name: manifest_artifact.to_string(), + reason, + })?; + let manifest: ImageSetManifest = serde_json::from_slice(&manifest_bytes).map_err(|error| { + BundleVerifyError::ImageSetManifestParse { + name: manifest_artifact.to_string(), + reason: error.to_string(), + } + })?; + validate_structure(&manifest).map_err(|reason| BundleVerifyError::ImageSetRefused { + name: manifest_artifact.to_string(), + reason, + })?; + if !matches!(&selection, ImageSetSelection::LocalKernel(_, _)) { + require_complete(&manifest, &ImageSetRequirement::current_train()).map_err(|reason| { + BundleVerifyError::ImageSetRefused { + name: manifest_artifact.to_string(), + reason, + } + })?; + } + + let selected = match &selection { + ImageSetSelection::Full => Ok(manifest.members.iter().collect()), + ImageSetSelection::LegacyRuntime(arch) => { + select_boot_asset_members(&manifest, *arch).map(|members| members.to_vec()) + } + ImageSetSelection::CliKernel(arch) => { + select_cli_boot_kernel(&manifest, *arch).map(|member| vec![member]) + } + ImageSetSelection::LocalKernel(arch, proof) => { + let kernel = bundle.find_by_role(&ArtifactRole::Kernel).ok_or_else(|| { + BundleVerifyError::ManifestParse("local kernel artifact missing".into()) + })?; + let (selected, _) = select_local_development_base( + &manifest, + *arch, + &kernel.sha256, + kernel.size_bytes, + proof, + )?; + Ok(vec![selected]) + } + }; + let members = selected.map_err(|reason| BundleVerifyError::ImageSetRefused { + name: manifest_artifact.to_string(), + reason, + })?; + let mut artifact_paths = BTreeMap::new(); + for image_artifact in members + .into_iter() + .flat_map(|member| member.artifacts.iter()) + { + let name = image_artifact.name.as_str(); + let bundle_artifact = bundle + .artifacts + .iter() + .find(|artifact| match &selection { + ImageSetSelection::CliKernel(_) | ImageSetSelection::LocalKernel(_, _) => { + artifact.role == ArtifactRole::Kernel + } + ImageSetSelection::Full | ImageSetSelection::LegacyRuntime(_) => { + artifact.name == name + } + }) + .ok_or_else(|| BundleVerifyError::ImageSetArtifactMissing { + name: name.to_string(), + })?; + if image_artifact.sha256.as_str() != bundle_artifact.sha256 { + return Err(BundleVerifyError::ImageSetArtifactDigestMismatch { + name: name.to_string(), + image_set: image_artifact.sha256.as_str().to_string(), + bundle: bundle_artifact.sha256.clone(), + }); + } + if image_artifact.size != bundle_artifact.size_bytes { + return Err(BundleVerifyError::ImageSetArtifactSizeMismatch { + name: name.to_string(), + image_set: image_artifact.size, + bundle: bundle_artifact.size_bytes, + }); + } + artifact_paths.insert(name.to_string(), bundle_artifact.path.clone()); + } + + Ok(VerifiedEmbeddedImageSet { + manifest_artifact: manifest_artifact.to_string(), + manifest_sha256: Sha256Hex::from_bytes(&manifest_bytes), + manifest, + artifact_paths, + }) +} diff --git a/crates/mvm-core/src/plan/bundle/local_development_base.rs b/crates/mvm-core/src/plan/bundle/local_development_base.rs new file mode 100644 index 0000000000..45d6de02a1 --- /dev/null +++ b/crates/mvm-core/src/plan/bundle/local_development_base.rs @@ -0,0 +1,123 @@ +//! Select the sealed local kernel and tar source bound by development proof. + +use super::{ + ArtifactFormat, BackendImageSupport, BundleVerifyError, GuestArch, ImageSetManifest, + ImageSetRole, LocalDevelopmentBaseProof, WorkloadImageProfile, select_workload_image, + validate_structure, +}; +use crate::image_set::{ImageSetMember, ImageSetProducer, MemberArtifact}; + +fn local_development_pair( + image_set: &ImageSetManifest, + arch: GuestArch, + profile: WorkloadImageProfile, +) -> Result<(&ImageSetMember, &ImageSetMember), BundleVerifyError> { + let refused = || { + BundleVerifyError::ManifestParse( + "local base has no unique compatible sealed kernel/rootfs/tar pair".into(), + ) + }; + if image_set.schema_version != crate::image_set::IMAGE_SET_SCHEMA_VERSION + || !matches!(image_set.producer, ImageSetProducer::LocalCheckouts(_)) + { + return Err(refused()); + } + validate_structure(image_set).map_err(|_| refused())?; + let mut kernels = image_set.members.iter().filter(|member| { + member.role == ImageSetRole::WorkloadKernel(profile) + && member.target.admits(arch) + && member.build_mode.is_none() + }); + let kernel = kernels.next().ok_or_else(refused)?; + if kernels.next().is_some() || kernel.artifacts.len() != 1 { + return Err(refused()); + } + let mut roots = image_set.members.iter().filter(|member| { + member.role == ImageSetRole::WorkloadRootfs(profile) + && member.target.admits(arch) + && member.build_mode == kernel.build_mode + }); + let rootfs = roots.next().ok_or_else(refused)?; + if roots.next().is_some() + || rootfs + .artifacts + .iter() + .filter(|artifact| artifact.format == ArtifactFormat::TarGz) + .count() + != 1 + { + return Err(refused()); + } + let mut selected = image_set.clone(); + selected.members.retain(|member| { + !matches!( + member.role, + ImageSetRole::WorkloadKernel(_) | ImageSetRole::WorkloadRootfs(_) + ) || member == kernel + || member == rootfs + }); + select_workload_image( + &selected, + profile, + arch, + &BackendImageSupport::for_backend(crate::vm_backend::BackendKind::Qemu) + .expect("QEMU supports portable workload boot"), + ) + .map_err(|_| refused())?; + Ok((kernel, rootfs)) +} + +/// Select the same sealed local kernel and tar source the bundle reader accepts. +/// These are declarations, not verified bytes or permission to boot. +pub fn select_local_development_source( + image_set: &ImageSetManifest, + arch: GuestArch, + profile: WorkloadImageProfile, +) -> Result<(&MemberArtifact, &MemberArtifact), BundleVerifyError> { + let (kernel, rootfs) = local_development_pair(image_set, arch, profile)?; + let tar = rootfs + .artifacts + .iter() + .find(|artifact| artifact.format == ArtifactFormat::TarGz) + .expect("unique tar checked by local development pair"); + Ok((&kernel.artifacts[0], tar)) +} + +/// Find exactly one local kernel and same-profile rootfs whose tar matches the proof. +pub fn select_local_development_base<'a>( + image_set: &'a ImageSetManifest, + arch: GuestArch, + kernel_sha256: &str, + kernel_size: u64, + proof: &LocalDevelopmentBaseProof, +) -> Result<(&'a ImageSetMember, &'a ImageSetMember), BundleVerifyError> { + let refused = || { + BundleVerifyError::ManifestParse( + "local base does not bind one matching kernel/rootfs source pair".into(), + ) + }; + let mut pairs = [ + WorkloadImageProfile::DefaultTenant, + WorkloadImageProfile::RootlessTenant, + ] + .into_iter() + .filter_map(|profile| { + let (kernel, rootfs) = local_development_pair(image_set, arch, profile).ok()?; + let selected_kernel = &kernel.artifacts[0]; + let tar = rootfs + .artifacts + .iter() + .find(|artifact| artifact.format == ArtifactFormat::TarGz) + .expect("unique tar checked by local development pair"); + (selected_kernel.sha256.as_str() == kernel_sha256 + && selected_kernel.size == kernel_size + && tar.sha256.as_str() == proof.composition_tar_sha256 + && tar.size == proof.composition_tar_size) + .then_some((kernel, rootfs)) + }); + let pair = pairs.next().ok_or_else(refused)?; + if pairs.next().is_some() { + return Err(refused()); + } + Ok(pair) +} diff --git a/crates/mvm-core/src/plan/bundle/stream.rs b/crates/mvm-core/src/plan/bundle/stream.rs index f3ef5d0c7d..ebf871afce 100644 --- a/crates/mvm-core/src/plan/bundle/stream.rs +++ b/crates/mvm-core/src/plan/bundle/stream.rs @@ -227,18 +227,35 @@ enum Destination<'a> { File(&'a Path), } +#[cfg(feature = "host-state")] +#[derive(Clone, Copy)] +enum ArchiveEntries { + Compatible, + Exact, +} + /// Walk an archive once, hashing every declared artifact into the /// destination `route` picks for it and checking it against the manifest. /// Returns the bytes of the artifacts routed to [`Destination::Capture`]. /// -/// Entries the manifest does not declare are skipped, as the in-memory -/// verifier skips them; a declared artifact that appears twice or never is -/// refused. +/// Compatible reads skip undeclared entries like the in-memory verifier; +/// exact reads refuse them. A declared artifact that appears twice or never +/// is always refused. #[cfg(feature = "host-state")] fn stream_artifacts<'p>( archive: impl Read, manifest: &BundleManifest, mut route: impl FnMut(&BundleArtifact) -> Destination<'p>, +) -> Result>, BundleVerifyError> { + stream_artifacts_with_entries(archive, manifest, &mut route, ArchiveEntries::Compatible) +} + +#[cfg(feature = "host-state")] +fn stream_artifacts_with_entries<'p>( + archive: impl Read, + manifest: &BundleManifest, + mut route: impl FnMut(&BundleArtifact) -> Destination<'p>, + entries: ArchiveEntries, ) -> Result>, BundleVerifyError> { let tar_error = |e: io::Error| BundleVerifyError::ManifestParse(format!("tar read: {e}")); let declared: BTreeMap<&str, &BundleArtifact> = manifest @@ -261,7 +278,20 @@ fn stream_artifacts<'p>( .into_owned(); ensure_safe_path(&path)?; budget.admit(&path, entry.size())?; + if matches!(entries, ArchiveEntries::Exact) && !entry.header().entry_type().is_file() { + return Err(BundleVerifyError::ManifestParse(format!( + "non-file archive entry {path}" + ))); + } let Some(artifact) = declared.get(path.as_str()) else { + if matches!(entries, ArchiveEntries::Exact) + && path != MANIFEST_FILENAME + && path != SIGNATURE_FILENAME + { + return Err(BundleVerifyError::ManifestParse(format!( + "unexpected archive entry {path}" + ))); + } continue; }; if entry.size() != artifact.size_bytes { @@ -363,8 +393,145 @@ pub struct VerifiedBundleFile { manifest_signature: [u8; 64], } +/// Selected signed rootfs and verity bytes, owned by a private temporary directory. +/// The paths cease to exist when this value is dropped. +#[derive(Debug)] +#[cfg(feature = "host-state")] +pub struct ExtractedBundleRootfs { + _directory: tempfile::TempDir, + pub rootfs: PathBuf, + pub verity: PathBuf, + pub roothash: String, +} + #[cfg(feature = "host-state")] impl VerifiedBundleFile { + fn verify_archive_pin(&self, mut archive: File) -> Result { + use std::io::{Seek, SeekFrom}; + let invalid = |reason: &str| BundleVerifyError::ManifestParse(reason.to_owned()); + let metadata = archive + .metadata() + .map_err(|error| invalid(&format!("snapshot metadata: {error}")))?; + if !metadata.is_file() || metadata.len() > super::MAX_BUNDLE_TOTAL_BYTES + 64 * 1024 * 1024 + { + return Err(invalid("snapshot is not a bounded regular archive")); + } + archive + .seek(SeekFrom::Start(0)) + .map_err(|error| invalid(&format!("snapshot rewind: {error}")))?; + let mut digest = HashingWriter::new(io::sink()); + io::copy( + &mut Read::by_ref(&mut archive).take(metadata.len() + 1), + &mut digest, + ) + .map_err(|error| invalid(&format!("snapshot hash: {error}")))?; + let (size, hash) = digest.finish(); + if size != metadata.len() || hash != self.bundle_sha256 { + return Err(invalid( + "retained snapshot differs from its original archive pin", + )); + } + archive + .seek(SeekFrom::Start(0)) + .map_err(|error| invalid(&format!("snapshot rewind: {error}")))?; + let (manifest_bytes, signature) = read_manifest_entries(&mut archive)?; + let signed: BundleManifest = serde_json::from_slice(&manifest_bytes) + .map_err(|error| invalid(&format!("snapshot manifest: {error}")))?; + if signed != self.manifest || signature.as_slice() != self.manifest_signature { + return Err(invalid("verified manifest differs from the archive pin")); + } + archive + .seek(SeekFrom::Start(0)) + .map_err(|error| invalid(&format!("snapshot rewind: {error}")))?; + Ok(archive) + } + + /// Extract the unique signed rootfs and matching verity sidecar from a pinned archive. + /// + /// The returned paths remain valid while the returned value lives. This does not + /// install the bundle or grant permission to boot without admission. + /// + /// # Errors + /// + /// Refuses missing or ambiguous roles, absent verity, changed archive bytes, + /// invalid entries, or artifact bytes differing from their signed declarations. + pub fn extract_rootfs_with_verity( + &self, + archive: File, + ) -> Result { + let invalid = |reason: &str| BundleVerifyError::ManifestParse(reason.to_owned()); + let mut paths = BTreeSet::new(); + let mut names = BTreeSet::new(); + if !self + .manifest + .artifacts + .iter() + .all(|artifact| paths.insert(&artifact.path) && names.insert(&artifact.name)) + { + return Err(invalid("signed artifact inventory is ambiguous")); + } + let select = |role| { + let mut matches = self.manifest.artifacts.iter().filter(|a| a.role == role); + let artifact = matches + .next() + .ok_or_else(|| invalid("required bundle role missing"))?; + if matches.next().is_some() + || artifact.size_bytes == 0 + || self + .manifest + .artifacts + .iter() + .filter(|a| a.path == artifact.path || a.name == artifact.name) + .count() + != 1 + { + return Err(invalid("selected signed bundle role is ambiguous")); + } + Ok(artifact) + }; + let rootfs = select(super::ArtifactRole::Rootfs)?; + let verity = self + .manifest + .verity + .as_ref() + .ok_or_else(|| invalid("bundle verity declaration missing"))?; + let sidecar = select(super::ArtifactRole::VerityHashSidecar)?; + if sidecar.name != verity.sidecar_artifact || sidecar.path == rootfs.path { + return Err(invalid( + "bundle verity sidecar does not match the signed role", + )); + } + let archive = self.verify_archive_pin(archive)?; + let directory = tempfile::Builder::new() + .prefix("bundle-rootfs-") + .tempdir() + .map_err(|error| invalid(&format!("private rootfs directory: {error}")))?; + crate::private_fs::create_owned_private_dir(directory.path()) + .map_err(|error| invalid(&format!("private rootfs permissions: {error}")))?; + let rootfs_path = directory.path().join("rootfs.ext4"); + let verity_path = directory.path().join("rootfs.verity"); + stream_artifacts_with_entries( + archive, + &self.manifest, + |artifact| { + if artifact.path == rootfs.path { + Destination::File(&rootfs_path) + } else if artifact.path == sidecar.path { + Destination::File(&verity_path) + } else { + Destination::Discard + } + }, + ArchiveEntries::Exact, + )?; + Ok(ExtractedBundleRootfs { + _directory: directory, + rootfs: rootfs_path, + verity: verity_path, + roothash: verity.roothash.clone(), + }) + } + /// Reopen a bounded member from an archive matching this verified file's /// full SHA-256. This is evidence, not permission to boot without admission. /// @@ -373,11 +540,10 @@ impl VerifiedBundleFile { /// Refuses a changed archive, missing or ambiguous artifact, or invalid bytes. pub fn read_small_verified_member( &self, - mut archive: File, + archive: File, name: &str, maximum: u64, ) -> Result, BundleVerifyError> { - use std::io::{Seek, SeekFrom}; let invalid = |reason: &str| BundleVerifyError::ManifestParse(reason.to_owned()); let mut matches = self .manifest @@ -403,38 +569,17 @@ impl VerifiedBundleFile { if member.size_bytes > maximum || member.size_bytes > MAX_IMAGE_SET_MANIFEST_BYTES { return Err(invalid("selected signed member exceeds its capture bound")); } - let metadata = archive - .metadata() - .map_err(|error| invalid(&format!("snapshot metadata: {error}")))?; - if !metadata.is_file() || metadata.len() > super::MAX_BUNDLE_TOTAL_BYTES + 64 * 1024 * 1024 - { - return Err(invalid("snapshot is not a bounded regular archive")); - } - archive - .seek(SeekFrom::Start(0)) - .map_err(|error| invalid(&format!("snapshot rewind: {error}")))?; - let mut digest = HashingWriter::new(io::sink()); - io::copy( - &mut Read::by_ref(&mut archive).take(metadata.len() + 1), - &mut digest, - ) - .map_err(|error| invalid(&format!("snapshot hash: {error}")))?; - let (size, hash) = digest.finish(); - if size != metadata.len() || hash != self.bundle_sha256 { - return Err(invalid( - "retained snapshot differs from its original archive pin", - )); - } - archive - .seek(SeekFrom::Start(0)) - .map_err(|error| invalid(&format!("snapshot rewind: {error}")))?; - let mut captured = stream_artifacts(archive, &self.manifest, |artifact| { - if artifact.path == member.path { - Destination::Capture - } else { - Destination::Discard - } - })?; + let mut captured = stream_artifacts( + self.verify_archive_pin(archive)?, + &self.manifest, + |artifact| { + if artifact.path == member.path { + Destination::Capture + } else { + Destination::Discard + } + }, + )?; captured .remove(&member.path) .ok_or_else(|| BundleVerifyError::ArtifactMissing { @@ -1365,6 +1510,36 @@ mod tests { out } + fn sealed_with_verity(dir: &Path, include_verity: bool) -> PathBuf { + let mut declared = manifest(vec![ + artifact("vmlinux", ArtifactRole::Kernel, b"kernel"), + artifact("rootfs.ext4", ArtifactRole::Rootfs, b"rootfs"), + ]); + let mut payload = vec![ + ("artifacts/vmlinux".to_owned(), b"kernel".to_vec()), + ("artifacts/rootfs.ext4".to_owned(), b"rootfs".to_vec()), + ]; + if include_verity { + declared.artifacts.push(artifact( + "rootfs.verity", + ArtifactRole::VerityHashSidecar, + b"verity", + )); + declared.verity = Some(super::super::VerityInfo { + roothash: "a".repeat(64), + sidecar_artifact: "rootfs.verity".into(), + }); + payload.push(("artifacts/rootfs.verity".to_owned(), b"verity".to_vec())); + } + let path = dir.join("verity.mvmpkg"); + std::fs::write( + &path, + crate::plan::bundle::write_bundle(&declared, &key(), payload).unwrap(), + ) + .unwrap(); + path + } + fn trust() -> OneKey { OneKey(key().verifying_key()) } @@ -1405,6 +1580,119 @@ mod tests { assert_eq!(streamed.bundle_sha256, sha256_hex(&bytes)); } + #[test] + fn verified_rootfs_extraction_owns_only_the_signed_pair() { + let dir = tempfile::tempdir().unwrap(); + let archive = sealed_with_verity(dir.path(), true); + let verified = verify_bundle_file(&archive, &trust()).unwrap(); + let extracted = verified + .extract_rootfs_with_verity(File::open(&archive).unwrap()) + .unwrap(); + assert_eq!(std::fs::read(&extracted.rootfs).unwrap(), b"rootfs"); + assert_eq!(std::fs::read(&extracted.verity).unwrap(), b"verity"); + assert_eq!(extracted.roothash, "a".repeat(64)); + let parent = extracted.rootfs.parent().unwrap().to_owned(); + assert_eq!(std::fs::read_dir(&parent).unwrap().count(), 2); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(&parent).unwrap().permissions().mode() & 0o077, + 0 + ); + } + drop(extracted); + assert!(!parent.exists()); + } + + #[test] + fn rootfs_extraction_refuses_changed_archive_and_missing_verity() { + let dir = tempfile::tempdir().unwrap(); + let archive = sealed_with_verity(dir.path(), true); + let verified = verify_bundle_file(&archive, &trust()).unwrap(); + flip(&archive, b"rootfs", b"ROOTFS"); + assert!( + verified + .extract_rootfs_with_verity(File::open(&archive).unwrap()) + .is_err() + ); + + let archive = sealed_with_verity(dir.path(), false); + let verified = verify_bundle_file(&archive, &trust()).unwrap(); + assert!( + verified + .extract_rootfs_with_verity(File::open(&archive).unwrap()) + .is_err() + ); + } + + #[test] + fn rootfs_extraction_refuses_mutated_verified_manifest() { + let dir = tempfile::tempdir().unwrap(); + let archive = sealed_with_verity(dir.path(), true); + let mut verified = verify_bundle_file(&archive, &trust()).unwrap(); + verified.manifest.verity.as_mut().unwrap().roothash = "b".repeat(64); + assert!( + verified + .extract_rootfs_with_verity(File::open(&archive).unwrap()) + .is_err() + ); + } + + #[test] + fn rootfs_extraction_refuses_unexpected_and_symlink_archive_entries() { + let dir = tempfile::tempdir().unwrap(); + let mut declared = manifest(vec![ + artifact("vmlinux", ArtifactRole::Kernel, b"k"), + artifact("rootfs.ext4", ArtifactRole::Rootfs, b"r"), + artifact("rootfs.verity", ArtifactRole::VerityHashSidecar, b"v"), + ]); + declared.verity = Some(super::super::VerityInfo { + roothash: "a".repeat(64), + sidecar_artifact: "rootfs.verity".into(), + }); + let manifest_bytes = canonical_manifest_bytes(&declared).unwrap(); + let signature = ed25519_dalek::Signer::sign(&key(), &manifest_bytes).to_bytes(); + for symlink in [false, true] { + let mut tar = tar::Builder::new(Vec::new()); + append_entry( + &mut tar, + MANIFEST_FILENAME, + manifest_bytes.len() as u64, + manifest_bytes.as_slice(), + ) + .unwrap(); + append_entry(&mut tar, SIGNATURE_FILENAME, 64, signature.as_slice()).unwrap(); + for (name, bytes) in [ + ("vmlinux", b"k".as_slice()), + ("rootfs.ext4", b"r"), + ("rootfs.verity", b"v"), + ] { + append_entry(&mut tar, &format!("artifacts/{name}"), 1, bytes).unwrap(); + } + if symlink { + let mut header = tar::Header::new_gnu(); + header.set_entry_type(tar::EntryType::Symlink); + header.set_size(0); + header.set_link_name("rootfs.ext4").unwrap(); + header.set_cksum(); + tar.append_data(&mut header, "artifacts/link", io::empty()) + .unwrap(); + } else { + append_entry(&mut tar, "artifacts/unexpected", 1, b"x".as_slice()).unwrap(); + } + let path = dir.path().join("extra.mvmpkg"); + std::fs::write(&path, tar.into_inner().unwrap()).unwrap(); + let verified = verify_bundle_file(&path, &trust()).unwrap(); + assert!( + verified + .extract_rootfs_with_verity(File::open(&path).unwrap()) + .is_err(), + "unexpected entry accepted: symlink={symlink}" + ); + } + } + fn flip(path: &Path, needle: &[u8], replacement: &[u8]) { let mut bytes = std::fs::read(path).unwrap(); let at = bytes diff --git a/crates/mvm-core/src/protocol/runtime_release.rs b/crates/mvm-core/src/protocol/runtime_release.rs index c254a1f1f9..ee4f72af78 100644 --- a/crates/mvm-core/src/protocol/runtime_release.rs +++ b/crates/mvm-core/src/protocol/runtime_release.rs @@ -17,6 +17,16 @@ pub struct RuntimeReleaseProof { pub arch: GuestArch, } +/// Untrusted PR-workflow runtime input. A release proof cannot stand in for it. +#[cfg(feature = "pr-test-guest-runtime")] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +pub struct PrTestRuntimeProof { + pub input: RuntimeReleaseProof, + pub pull_request: u64, +} + /// Untrusted record of the artifacts selected for a launch. A checkpoint must /// authenticate this record and reverify its release and artifact bytes before /// using it; reading a metadata file does not create runtime authority. @@ -67,6 +77,10 @@ pub enum RuntimeBindingProvenance { arch: GuestArch, archive_sha256: String, }, + #[cfg(feature = "pr-test-guest-runtime")] + PrTest { + proof: PrTestRuntimeProof, + }, } impl RuntimeLaunchBinding { @@ -97,6 +111,10 @@ impl RuntimeLaunchBinding { config.legacy_portable_runtime = Some(proof.clone()); config.legacy_bundle_runtime = None; config.runtime_release_proof = None; + #[cfg(feature = "pr-test-guest-runtime")] + { + config.pr_test_runtime_proof = None; + } config.source_runtime_archive_sha256 = None; config.unsigned_runtime_development = false; config.bundle_boot_assets = None; @@ -118,6 +136,10 @@ impl RuntimeLaunchBinding { config.runtime_overlay_verity_path = None; config.runtime_overlay_roothash = None; config.runtime_release_proof = None; + #[cfg(feature = "pr-test-guest-runtime")] + { + config.pr_test_runtime_proof = None; + } config.unsigned_runtime_development = false; config.source_runtime_archive_sha256 = None; } @@ -138,10 +160,25 @@ impl CliRuntimeLaunchBinding { config.initrd_path = Some(self.initramfs_path.display().to_string()); config.runtime_overlay_roothash = Some(self.overlay_roothash.clone()); config.runtime_release_proof = self.release_proof().cloned(); - config.unsigned_runtime_development = self.release_proof().is_none(); + #[cfg(feature = "pr-test-guest-runtime")] + { + config.pr_test_runtime_proof = match &self.provenance { + RuntimeBindingProvenance::PrTest { proof } => Some(proof.clone()), + RuntimeBindingProvenance::Release { .. } + | RuntimeBindingProvenance::Source { .. } => None, + }; + } + config.unsigned_runtime_development = match &self.provenance { + RuntimeBindingProvenance::Source { .. } => true, + RuntimeBindingProvenance::Release { .. } => false, + #[cfg(feature = "pr-test-guest-runtime")] + RuntimeBindingProvenance::PrTest { .. } => false, + }; config.source_runtime_archive_sha256 = match &self.provenance { RuntimeBindingProvenance::Release { .. } => None, RuntimeBindingProvenance::Source { archive_sha256, .. } => Some(archive_sha256.clone()), + #[cfg(feature = "pr-test-guest-runtime")] + RuntimeBindingProvenance::PrTest { .. } => None, }; } @@ -149,6 +186,8 @@ impl CliRuntimeLaunchBinding { match &self.provenance { RuntimeBindingProvenance::Release { proof } => Some(proof), RuntimeBindingProvenance::Source { .. } => None, + #[cfg(feature = "pr-test-guest-runtime")] + RuntimeBindingProvenance::PrTest { .. } => None, } } @@ -156,6 +195,8 @@ impl CliRuntimeLaunchBinding { match &self.provenance { RuntimeBindingProvenance::Release { proof } => &proof.version, RuntimeBindingProvenance::Source { version, .. } => version, + #[cfg(feature = "pr-test-guest-runtime")] + RuntimeBindingProvenance::PrTest { proof } => &proof.input.version, } } @@ -163,6 +204,8 @@ impl CliRuntimeLaunchBinding { match &self.provenance { RuntimeBindingProvenance::Release { proof } => proof.arch, RuntimeBindingProvenance::Source { arch, .. } => *arch, + #[cfg(feature = "pr-test-guest-runtime")] + RuntimeBindingProvenance::PrTest { proof } => proof.input.arch, } } } @@ -234,4 +277,45 @@ mod tests { wrapped.apply_to_config(&mut config); assert_eq!(config.source_runtime_archive_sha256, None); } + + #[cfg(feature = "pr-test-guest-runtime")] + #[test] + fn pr_test_proof_stays_distinct_from_release_and_rejects_claimed_verification() { + let proof = PrTestRuntimeProof { + input: RuntimeReleaseProof { + archive: "/pr/archive".into(), + checksum: "/pr/checksum".into(), + signature_bundle: "/pr/archive.bundle".into(), + archive_sha256: "a".repeat(64), + version: "1.2.3".into(), + arch: GuestArch::X86_64, + }, + pull_request: 42, + }; + let binding = RuntimeLaunchBinding::CliAssembly(CliRuntimeLaunchBinding { + provenance: RuntimeBindingProvenance::PrTest { + proof: proof.clone(), + }, + overlay_path: "/pr/overlay".into(), + overlay_verity_path: "/pr/verity".into(), + initramfs_path: "/pr/initrd".into(), + overlay_roothash: "b".repeat(64), + }); + let json = serde_json::to_value(&binding).unwrap(); + assert_eq!( + serde_json::from_value::(json.clone()).unwrap(), + binding + ); + let mut forged = json.clone(); + forged["provenance"]["proof"]["verified"] = true.into(); + assert!(serde_json::from_value::(forged).is_err()); + let mut impersonated = json; + impersonated["provenance"]["kind"] = "release".into(); + assert!(serde_json::from_value::(impersonated).is_err()); + let mut config = super::super::vm_backend::VmStartConfig::default(); + binding.apply_to_config(&mut config); + assert_eq!(config.pr_test_runtime_proof, Some(proof)); + assert!(config.runtime_release_proof.is_none()); + assert!(!config.unsigned_runtime_development); + } } diff --git a/crates/mvm-core/src/protocol/vm_backend.rs b/crates/mvm-core/src/protocol/vm_backend.rs index 76b5e745fa..f8a2f86ef9 100644 --- a/crates/mvm-core/src/protocol/vm_backend.rs +++ b/crates/mvm-core/src/protocol/vm_backend.rs @@ -155,6 +155,9 @@ pub struct VmStartConfig { /// Untrusted release inputs. The launch boundary authenticates the archive /// and snapshots the selected boot artifacts before a backend can read them. pub runtime_release_proof: Option, + /// PR-workflow signed test runtime, never release provenance. + #[cfg(feature = "pr-test-guest-runtime")] + pub pr_test_runtime_proof: Option, /// Explicit unsigned contributor runtime. Never sufficient for production /// assurance, regardless of cache fingerprints or declared protocol. pub unsigned_runtime_development: bool, diff --git a/crates/mvm-core/src/registry_pack.rs b/crates/mvm-core/src/registry_pack.rs index 94fa7b4f8f..a241aa1f3c 100644 --- a/crates/mvm-core/src/registry_pack.rs +++ b/crates/mvm-core/src/registry_pack.rs @@ -1045,7 +1045,9 @@ fn validate_registry_pack_manifest( pub mod application; pub mod application_bundle; +pub mod application_component; mod builtin_author_keys; +pub mod component_layer; #[cfg(any(test, feature = "test-support"))] pub use builtin_author_keys::register_test_author_key; pub use builtin_author_keys::{ @@ -1054,8 +1056,9 @@ pub use builtin_author_keys::{ mod dispatch; pub mod runtime_layer; pub use dispatch::{ - AuthenticatedRegistryManifest, VerifiedRegistryApplication, VerifiedRegistryManifest, - VerifiedRegistryRuntimeLayer, adopt_registry_manifest, verify_registry_manifest, + AuthenticatedRegistryManifest, VerifiedRegistryApplication, + VerifiedRegistryApplicationComponent, VerifiedRegistryManifest, VerifiedRegistryRuntimeLayer, + adopt_registry_manifest, verify_registry_manifest, }; pub(crate) fn verify_registry_manifest_with( diff --git a/crates/mvm-core/src/registry_pack/application_component.rs b/crates/mvm-core/src/registry_pack/application_component.rs new file mode 100644 index 0000000000..4d29722e7f --- /dev/null +++ b/crates/mvm-core/src/registry_pack/application_component.rs @@ -0,0 +1,467 @@ +//! Strict, nonbootable application-component inputs from signed registry metadata. +//! +//! The descriptor authenticates requirements and byte pins, not base suitability +//! or execution authority. Callers must separately admit the selected base and +//! interpret the archive and policy within that admission boundary. + +use std::collections::BTreeSet; +use std::io::Read; +use std::path::Path; + +use serde::{Deserialize, Deserializer, Serialize}; +use thiserror::Error; + +use super::application::{deserialize_object, validate_application_source}; +use super::{ + AuthenticatedRegistryManifest, MAX_REGISTRY_DESCRIPTOR_BYTES, PackPin, PackReference, + RegistryPackFile, RegistryPackPublisherPolicy, RegistryPackSignatureChecker, + RegistryPackVerification, RegistryPackVerificationError, VerifiedRegistryApplicationComponent, +}; +use crate::packs::Sha256Hex; +use crate::plan::bundle::HashingWriter; +use crate::registry_pack_store::RegistryPackStoreError; + +/// The application-component outer manifest version. +pub const APPLICATION_COMPONENT_MANIFEST_SCHEMA_VERSION: u32 = 3; +const MAX_COMPONENT_BYTES: u64 = 2 * 1024 * 1024 * 1024; +const GROUP_FILE: &str = "pack/group.toml"; +const PROFILE_FILE: &str = "pack/profile.toml"; + +/// Exact kind discriminator; never a legacy or complete application. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ApplicationComponentKind { + ApplicationComponent, +} + +/// Signed, closed outer descriptor with policy files kept separate from the archive. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RegistryApplicationComponentManifest { + pub schema_version: u32, + pub kind: ApplicationComponentKind, + pub reference: PackReference, + pub description: String, + #[serde(deserialize_with = "deserialize_object")] + pub application_component: ApplicationComponent, + pub files: Vec, +} + +/// One architecture's application archive, requirements and named literal commands. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ApplicationComponent { + pub schema_version: u32, + pub guest_arch: String, + #[serde(deserialize_with = "deserialize_object")] + pub requirements: ComponentRequirements, + #[serde(deserialize_with = "deserialize_object")] + pub archive: ComponentArchive, + pub entrypoints: Vec, +} + +/// Requirements to compare with a selected signed base, not capabilities granted. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ComponentRequirements { + pub filesystem_abi: u32, + pub libc: String, + #[serde( + default, + deserialize_with = "present_loader", + skip_serializing_if = "Option::is_none" + )] + pub loader: Option, + pub minimum_kernel: String, + pub required_capabilities: Vec, +} + +fn present_loader<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { + String::deserialize(deserializer).map(Some) +} + +/// External, canonical locator and exact bytes authenticated by the descriptor. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ComponentArchive { + pub source: String, + pub archive_sha256: Sha256Hex, + pub archive_size: u64, +} + +/// Named literal argv; only `default` is a candidate for implicit selection. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ComponentEntrypoint { + pub name: String, + pub argv: Vec, +} + +fn invalid(reason: &str) -> RegistryPackVerificationError { + RegistryPackVerificationError::ManifestParse(reason.to_string()) +} + +fn canonical_name(name: &str) -> bool { + name.as_bytes().first().is_some_and(u8::is_ascii_lowercase) + && name.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'-') + }) +} + +fn absolute_path(path: &str) -> bool { + path.starts_with('/') + && path[1..] + .split('/') + .all(|part| !matches!(part, "" | "." | "..")) + && !path.chars().any(|c| c.is_ascii_control() || c == '\\') +} + +fn kernel_version(version: &str) -> bool { + version.split('.').count() == 3 + && version.split('.').all(|part| { + !part.is_empty() + && (part == "0" || !part.starts_with('0')) + && part.bytes().all(|byte| byte.is_ascii_digit()) + }) +} + +/// Parse only outer schema 3 / `application_component`, nested schema 1. +/// +/// # Errors +/// +/// Rejects oversized, duplicate, unknown or noncanonical descriptor data. +/// Parsing alone does not authenticate the publisher or any external bytes. +pub fn parse_application_component_manifest( + bytes: &[u8], +) -> Result { + if bytes.len() > MAX_REGISTRY_DESCRIPTOR_BYTES { + return Err(invalid("application component descriptor exceeds 1 MiB")); + } + let mut deserializer = serde_json::Deserializer::from_slice(bytes); + let manifest: RegistryApplicationComponentManifest = deserialize_object(&mut deserializer) + .map_err(|_| invalid("application component descriptor has invalid or non-closed JSON"))?; + deserializer + .end() + .map_err(|_| invalid("application component descriptor has trailing data"))?; + let component = &manifest.application_component; + if manifest.schema_version != APPLICATION_COMPONENT_MANIFEST_SCHEMA_VERSION + || component.schema_version != 1 + { + return Err(invalid( + "application component requires outer schema 3 and nested schema 1", + )); + } + let version = manifest.reference.version().map(|v| v.as_str()); + if version.is_none_or(|v| !kernel_version(v)) || manifest.description.trim().is_empty() { + return Err(invalid( + "application component requires an exact numeric version and description", + )); + } + if !matches!(component.guest_arch.as_str(), "aarch64" | "x86_64") { + return Err(invalid( + "application component guest architecture is not canonical", + )); + } + let archive = &component.archive; + if archive.archive_size == 0 || archive.archive_size > MAX_COMPONENT_BYTES { + return Err(invalid( + "application component archive size is out of bounds", + )); + } + validate_application_source(&archive.source)?; + validate_component_requirements(&component.requirements)?; + let mut names = BTreeSet::new(); + if component.entrypoints.len() > 16 + || component.entrypoints.iter().any(|entry| { + !canonical_name(&entry.name) + || !names.insert(&entry.name) + || entry.argv.is_empty() + || entry.argv.len() > 64 + || !absolute_path(&entry.argv[0]) + || entry + .argv + .iter() + .any(|arg| arg.is_empty() || arg.chars().count() > 4096 || arg.contains('\0')) + }) + { + return Err(invalid( + "application component entrypoints are invalid or duplicated", + )); + } + let mut paths = BTreeSet::new(); + if manifest.files.is_empty() + || manifest.files.len() > 2 + || manifest.files.iter().any(|file| { + !matches!(file.path.as_str(), GROUP_FILE | PROFILE_FILE) + || !paths.insert(&file.path) + || file.size == 0 + || file.size > MAX_REGISTRY_DESCRIPTOR_BYTES as u64 + }) + { + return Err(invalid( + "application component requires one or two pinned policy files", + )); + } + Ok(manifest) +} + +pub(super) fn validate_component_requirements( + requirements: &ComponentRequirements, +) -> Result<(), RegistryPackVerificationError> { + if requirements.filesystem_abi != 1 + || !kernel_version(&requirements.minimum_kernel) + || match requirements.libc.as_str() { + "static" => requirements.loader.is_some(), + "musl" | "glibc" => requirements + .loader + .as_deref() + .is_none_or(|p| !absolute_path(p)), + _ => true, + } + { + return Err(invalid( + "application component requirements are not canonical", + )); + } + let mut capabilities = BTreeSet::new(); + if requirements.required_capabilities.len() > 16 + || requirements + .required_capabilities + .iter() + .any(|name| !canonical_name(name) || !capabilities.insert(name)) + { + return Err(invalid( + "application component capabilities are invalid or duplicated", + )); + } + Ok(()) +} + +/// Verify the exact lock pin, namespace publisher and current signed revocations. +/// +/// # Errors +/// +/// Refuses missing or drifted pins, untrusted signatures, invalid wire shapes, +/// or a stale, absent or revoking feed. No archive is fetched. +pub fn verify_application_component_manifest( + request: &RegistryPackVerification<'_>, +) -> Result { + let verified = + super::dispatch::verify_component_with(request, super::check_registry_pack_signature)?; + verified.check_current_revocations()?; + Ok(verified) +} + +impl VerifiedRegistryApplicationComponent { + /// Recheck retained signed bytes against today's publisher enrollment and lock pin. + /// + /// # Errors + /// + /// Refuses withdrawn identities or altered descriptor/signature bytes. + pub fn reverify_publisher(&self) -> Result<(), ApplicationComponentInputError> { + let loaded = crate::registry_pack_store::load_publisher_policy_or_official_default( + &crate::config::registry_pack_publisher_policy_path(), + )?; + self.reverify_publisher_with(&loaded.policy, super::check_registry_pack_signature) + } + + fn reverify_publisher_with( + &self, + policy: &RegistryPackPublisherPolicy, + check: RegistryPackSignatureChecker, + ) -> Result<(), ApplicationComponentInputError> { + let pin = PackPin::new(self.reference().clone(), self.manifest_sha256().clone())?; + let lock = super::PackLockfile::new(vec![pin])?; + let request = RegistryPackVerification::new( + self.reference(), + self.manifest_bytes(), + self.signature_bundle(), + &lock, + policy, + ); + if super::dispatch::verify_component_with(&request, check)? != *self { + return Err(invalid("retained component publisher identity changed").into()); + } + Ok(()) + } +} + +/// Local verified bytes, not a tar extraction or a launch grant. +#[derive(Debug)] +pub struct VerifiedApplicationComponentInputs { + descriptor: VerifiedRegistryApplicationComponent, + snapshot: tempfile::NamedTempFile, + group: Option>, + profile: Option>, +} + +impl VerifiedApplicationComponentInputs { + /// Signed component requirements and archive pin. + pub fn descriptor(&self) -> &VerifiedRegistryApplicationComponent { + &self.descriptor + } + + /// Exact signed group policy bytes, when declared. + pub fn group(&self) -> Option<&[u8]> { + self.group.as_deref() + } + + /// Exact signed profile policy bytes, when declared. + pub fn profile(&self) -> Option<&[u8]> { + self.profile.as_deref() + } + + /// Recheck publisher enrollment, revocations, snapshot and policy bytes. + /// + /// # Errors + /// + /// Refuses changed evidence or payload and stale or withdrawn trust. + pub fn reverify(&self) -> Result<(), ApplicationComponentInputError> { + self.descriptor.reverify_publisher()?; + self.descriptor.check_current_revocations()?; + self.reverify_bytes() + } + + fn reverify_bytes(&self) -> Result<(), ApplicationComponentInputError> { + let expected = &self.descriptor.manifest().application_component.archive; + let mut reader = self.snapshot.reopen()?; + if reader.metadata()?.len() != expected.archive_size { + return Err(ApplicationComponentInputError::Size); + } + let mut hasher = HashingWriter::new(std::io::sink()); + std::io::copy( + &mut reader.by_ref().take(expected.archive_size + 1), + &mut hasher, + )?; + let (size, digest) = hasher.finish(); + if size != expected.archive_size || digest != expected.archive_sha256.as_str() { + return Err(ApplicationComponentInputError::Digest); + } + for file in &self.descriptor.manifest().files { + let bytes = match file.path.as_str() { + GROUP_FILE => self.group(), + PROFILE_FILE => self.profile(), + _ => None, + } + .ok_or(ApplicationComponentInputError::Digest)?; + if bytes.len() as u64 != file.size || Sha256Hex::from_bytes(bytes) != file.sha256 { + return Err(ApplicationComponentInputError::Digest); + } + } + Ok(()) + } + + /// Recheck trust and return a private archive stream; no extraction occurs. + /// + /// # Errors + /// + /// Refuses altered bytes or withdrawn trust. + pub fn open_verified_archive(&self) -> Result { + self.reverify()?; + Ok(std::fs::File::open(self.snapshot.path())?) + } +} + +/// Failure to authenticate a component's descriptor or locally supplied bytes. +#[derive(Debug, Error)] +pub enum ApplicationComponentInputError { + #[error(transparent)] + Verification(#[from] RegistryPackVerificationError), + #[error(transparent)] + Trust(#[from] RegistryPackStoreError), + #[error(transparent)] + Lock(#[from] super::RegistryPackError), + #[error("application component input I/O failed: {0}")] + Io(#[from] std::io::Error), + #[error("application component archive is not a regular file matching its signed size")] + Size, + #[error("application component bytes differ from their signed descriptor")] + Digest, +} + +/// Snapshot and hash an external archive and all signed policy files. +/// +/// # Errors +/// +/// Refuses missing, linked, special, changed, oversized or mismatched bytes, +/// and checks current publisher trust and revocation before returning custody. +pub fn verify_application_component_inputs( + descriptor: &VerifiedRegistryApplicationComponent, + archive: &Path, + files_root: &Path, +) -> Result { + verify_inputs_with( + descriptor, + archive, + files_root, + VerifiedRegistryApplicationComponent::reverify_publisher, + |descriptor| descriptor.check_current_revocations().map_err(Into::into), + ) +} + +fn verify_inputs_with( + descriptor: &VerifiedRegistryApplicationComponent, + archive: &Path, + files_root: &Path, + publisher: impl FnOnce( + &VerifiedRegistryApplicationComponent, + ) -> Result<(), ApplicationComponentInputError>, + revocations: impl FnOnce( + &VerifiedRegistryApplicationComponent, + ) -> Result<(), ApplicationComponentInputError>, +) -> Result { + publisher(descriptor)?; + revocations(descriptor)?; + let expected = &descriptor.manifest().application_component.archive; + let input = super::local_input::open_sized_regular(archive, expected.archive_size).map_err( + |error| { + if error.kind() == std::io::ErrorKind::Other { + ApplicationComponentInputError::Size + } else { + ApplicationComponentInputError::Io(error) + } + }, + )?; + let mut snapshot = tempfile::NamedTempFile::new()?; + let mut hasher = HashingWriter::new(snapshot.as_file_mut()); + std::io::copy(&mut input.take(expected.archive_size + 1), &mut hasher)?; + let (size, digest) = hasher.finish(); + if size != expected.archive_size { + return Err(ApplicationComponentInputError::Size); + } + if digest != expected.archive_sha256.as_str() { + return Err(ApplicationComponentInputError::Digest); + } + snapshot.as_file().sync_all()?; + let mut group = None; + let mut profile = None; + for file in &descriptor.manifest().files { + let mut source = super::local_input::open_payload(files_root, &file.path, file.size)?; + let before = source.metadata()?; + let mut bytes = Vec::new(); + source + .by_ref() + .take(file.size + 1) + .read_to_end(&mut bytes)?; + super::local_input::ensure_unchanged(&source, &before, files_root, &file.path, file.size)?; + if bytes.len() as u64 != file.size || Sha256Hex::from_bytes(&bytes) != file.sha256 { + return Err(ApplicationComponentInputError::Digest); + } + match file.path.as_str() { + GROUP_FILE => group = Some(bytes), + PROFILE_FILE => profile = Some(bytes), + _ => return Err(ApplicationComponentInputError::Digest), + } + } + let held = VerifiedApplicationComponentInputs { + descriptor: descriptor.clone(), + snapshot, + group, + profile, + }; + held.reverify_bytes()?; + Ok(held) +} + +#[cfg(test)] +mod tests; diff --git a/crates/mvm-core/src/registry_pack/application_component/tests.rs b/crates/mvm-core/src/registry_pack/application_component/tests.rs new file mode 100644 index 0000000000..e9c6eba4b6 --- /dev/null +++ b/crates/mvm-core/src/registry_pack/application_component/tests.rs @@ -0,0 +1,328 @@ +use serde_json::{Value, json}; + +use super::*; +use crate::registry_pack::tests::{accept_signature, publisher_policy}; +use crate::registry_pack::{PackLockfile, PackPin}; + +const POLICY: &[u8] = b"required = true\n"; +const ARCHIVE: &[u8] = b"application tar bytes"; + +fn document() -> Value { + json!({ + "schema_version": 3, + "kind": "application_component", + "reference": "runtime/worker@1.2.3", + "description": "portable worker", + "application_component": { + "schema_version": 1, + "guest_arch": "aarch64", + "requirements": { + "filesystem_abi": 1, + "libc": "musl", + "loader": "/lib/ld-musl-aarch64.so.1", + "minimum_kernel": "6.1.0", + "required_capabilities": ["pty"] + }, + "archive": { + "source": "https://example.test/worker.tar", + "archive_sha256": Sha256Hex::from_bytes(ARCHIVE), + "archive_size": ARCHIVE.len() + }, + "entrypoints": [ + {"name": "default", "argv": ["/usr/bin/worker", "--serve"]}, + {"name": "migrate", "argv": ["/usr/bin/worker", "--migrate"]} + ] + }, + "files": [{ + "path": "pack/group.toml", + "sha256": Sha256Hex::from_bytes(POLICY), + "size": POLICY.len() + }] + }) +} + +fn parse( + value: &Value, +) -> Result { + parse_application_component_manifest(&serde_json::to_vec(value).unwrap()) +} + +fn authenticated(value: &Value) -> VerifiedRegistryApplicationComponent { + let bytes = serde_json::to_vec(value).unwrap(); + let reference: PackReference = value["reference"].as_str().unwrap().parse().unwrap(); + let lock = PackLockfile::new(vec![ + PackPin::new(reference.clone(), Sha256Hex::from_bytes(&bytes)).unwrap(), + ]) + .unwrap(); + let policy = publisher_policy(); + super::super::dispatch::verify_component_with( + &RegistryPackVerification::new(&reference, &bytes, b"signed", &lock, &policy), + accept_signature, + ) + .unwrap() +} + +#[test] +fn component_has_exact_versioned_shape_and_does_not_fall_back() { + let manifest = parse(&document()).unwrap(); + assert_eq!(manifest.reference.to_string(), "runtime/worker@1.2.3"); + assert_eq!( + manifest.application_component.entrypoints[0].name, + "default" + ); + let bytes = serde_json::to_vec(&document()).unwrap(); + assert!(super::super::application::parse_application_manifest(&bytes).is_err()); + assert!(super::super::runtime_layer::parse_runtime_layer_manifest(&bytes).is_err()); + for (field, replacement) in [ + ("schema_version", json!(2)), + ("kind", json!("runtime_layer")), + ("reference", json!("runtime/worker@1.2.3+build")), + ("description", json!(" ")), + ] { + let mut value = document(); + value[field] = replacement; + assert!(parse(&value).is_err(), "{field}"); + } + for field in [ + "image", + "kernel", + "application", + "payload", + "network", + "role", + ] { + let mut value = document(); + value[field] = json!({}); + assert!(parse(&value).is_err(), "{field}"); + } + let mut duplicate = serde_json::to_string(&document()).unwrap(); + duplicate = duplicate.replacen( + "\"kind\":", + "\"kind\":\"application_component\",\"kind\":", + 1, + ); + assert!(parse_application_component_manifest(duplicate.as_bytes()).is_err()); + assert!( + parse_application_component_manifest(&vec![b' '; MAX_REGISTRY_DESCRIPTOR_BYTES + 1]) + .is_err() + ); +} + +#[test] +fn requirements_and_entrypoints_are_strict_and_bounded() { + let base = document(); + let mut cases = Vec::new(); + for (key, bad) in [ + ("filesystem_abi", json!(2)), + ("libc", json!("unknown")), + ("loader", json!("/lib/../ld.so")), + ("minimum_kernel", json!("06.1.0")), + ("required_capabilities", json!(["pty", "pty"])), + ] { + let mut value = base.clone(); + value["application_component"]["requirements"][key] = bad; + cases.push(value); + } + for (key, bad) in [ + ("source", json!("http://example.test/app.tar")), + ("archive_sha256", json!("A".repeat(64))), + ("archive_size", json!(MAX_COMPONENT_BYTES + 1)), + ] { + let mut value = base.clone(); + value["application_component"]["archive"][key] = bad; + cases.push(value); + } + for (key, bad) in [ + ("name", json!("NotCanonical")), + ("argv", json!(["relative"])), + ] { + let mut value = base.clone(); + value["application_component"]["entrypoints"][0][key] = bad; + cases.push(value); + } + let mut value = base.clone(); + value["application_component"]["requirements"]["loader"] = Value::Null; + cases.push(value); + let mut value = base.clone(); + value["application_component"]["requirements"]["libc"] = json!("static"); + cases.push(value); + let mut value = base.clone(); + value["application_component"]["entrypoints"][1]["name"] = json!("default"); + cases.push(value); + let mut value = base.clone(); + value["application_component"]["entrypoints"][0]["argv"] = json!(["/bin/app", "\0"]); + cases.push(value); + for (index, value) in cases.iter().enumerate() { + assert!(parse(value).is_err(), "invalid case {index}"); + } + let mut static_ok = base; + static_ok["application_component"]["requirements"]["libc"] = json!("static"); + static_ok["application_component"]["requirements"] + .as_object_mut() + .unwrap() + .remove("loader"); + static_ok["application_component"]["entrypoints"] = json!([]); + assert!(parse(&static_ok).is_ok()); +} + +#[test] +fn policy_inventory_requires_exact_signed_files() { + let base = document(); + for bad in [ + json!([]), + json!([{"path": "pack/other.toml", "sha256": Sha256Hex::from_bytes(POLICY), "size": POLICY.len()}]), + json!([base["files"][0].clone(), base["files"][0].clone()]), + json!([{"path": "pack/group.toml", "sha256": Sha256Hex::from_bytes(POLICY), "size": 0}]), + ] { + let mut value = base.clone(); + value["files"] = bad; + assert!(parse(&value).is_err()); + } + let mut good = base; + good["files"].as_array_mut().unwrap().push(json!({ + "path": "pack/profile.toml", "sha256": Sha256Hex::from_bytes(b"profile"), "size": 7 + })); + assert!(parse(&good).is_ok()); +} + +#[test] +fn locked_publisher_identity_and_retained_bytes_are_bound() { + let value = document(); + let bytes = serde_json::to_vec(&value).unwrap(); + let reference: PackReference = "runtime/worker@1.2.3".parse().unwrap(); + let lock = PackLockfile::new(vec![ + PackPin::new(reference.clone(), Sha256Hex::from_bytes(&bytes)).unwrap(), + ]) + .unwrap(); + let policy = publisher_policy(); + let request = RegistryPackVerification::new(&reference, &bytes, b"signed", &lock, &policy); + let verified = + super::super::dispatch::verify_component_with(&request, accept_signature).unwrap(); + assert_eq!(verified.manifest_bytes(), bytes); + assert_eq!(verified.signature_bundle(), b"signed"); + assert_eq!(verified.manifest_sha256(), &Sha256Hex::from_bytes(&bytes)); + verified + .reverify_publisher_with(&policy, accept_signature) + .unwrap(); + assert!( + verified + .reverify_publisher_with( + &RegistryPackPublisherPolicy::new(vec![]).unwrap(), + accept_signature + ) + .is_err() + ); + let mut altered = bytes.clone(); + altered.push(b' '); + assert!( + super::super::dispatch::verify_component_with( + &RegistryPackVerification::new(&reference, &altered, b"signed", &lock, &policy), + accept_signature, + ) + .is_err() + ); + let other: PackReference = "runtime/other@1.2.3".parse().unwrap(); + assert!( + super::super::dispatch::verify_component_with( + &RegistryPackVerification::new(&other, &bytes, b"signed", &lock, &policy), + accept_signature, + ) + .is_err() + ); +} + +#[test] +fn authenticated_component_uses_signed_current_revocation_feed() { + use crate::packs::KeylessTrust; + use crate::registry_pack_revocation::{ + RegistryPackRevocationError, verify_registry_pack_revocations_with, + }; + + let descriptor = authenticated(&document()); + let now = chrono::Utc::now(); + let trust = KeylessTrust { + issuer: "test revocation issuer".into(), + accepted_identities: vec!["test revocation signer".into()], + }; + for (identities, digests, revoked) in [ + (Vec::new(), Vec::new(), false), + (vec![descriptor.signer().identity.clone()], Vec::new(), true), + (Vec::new(), vec![descriptor.manifest_sha256().clone()], true), + ] { + let document = json!({ + "schema_version": 1, + "sequence": 1, + "issued_at": now - chrono::Duration::hours(1), + "not_after": now + chrono::Duration::hours(1), + "revoked_identities": identities, + "revoked_manifests": digests, + }); + let feed = verify_registry_pack_revocations_with( + &serde_json::to_vec(&document).unwrap(), + b"signed feed", + &trust, + now, + None, + |_, _, _| Ok(()), + ) + .unwrap(); + let result = feed.check_verified_pack_at(&descriptor, now); + assert_eq!(result.is_err(), revoked); + if revoked { + assert!(matches!( + result, + Err(RegistryPackRevocationError::RevokedIdentity { .. } + | RegistryPackRevocationError::RevokedManifest { .. }) + )); + } + assert!(matches!( + feed.check_verified_pack_at(&descriptor, now + chrono::Duration::hours(2)), + Err(RegistryPackRevocationError::Expired) + )); + } +} + +#[test] +fn private_snapshots_refuse_changed_archive_and_policy() { + let descriptor = authenticated(&document()); + let temporary = tempfile::tempdir().unwrap(); + let archive = temporary.path().join("app.tar"); + let policies = temporary.path().join("payload"); + std::fs::create_dir_all(policies.join("pack")).unwrap(); + std::fs::write(&archive, ARCHIVE).unwrap(); + std::fs::write(policies.join(GROUP_FILE), POLICY).unwrap(); + // The production entrypoint also requires current publisher enrollment and + // a signed feed; exercise its byte-verification core without a network feed. + std::fs::write(&archive, b"wrong length").unwrap(); + assert!(verify_inputs_with(&descriptor, &archive, &policies, |_| Ok(()), |_| Ok(())).is_err()); + std::fs::write(&archive, b"wrong archive bytes!!").unwrap(); + assert!(matches!( + verify_inputs_with(&descriptor, &archive, &policies, |_| Ok(()), |_| Ok(())), + Err(ApplicationComponentInputError::Digest) + )); + std::fs::write(&archive, ARCHIVE).unwrap(); + std::fs::write(policies.join(GROUP_FILE), b"wrong policy...\n").unwrap(); + assert!(matches!( + verify_inputs_with(&descriptor, &archive, &policies, |_| Ok(()), |_| Ok(())), + Err(ApplicationComponentInputError::Digest) + )); + std::fs::write(policies.join(GROUP_FILE), POLICY).unwrap(); + let input = + verify_inputs_with(&descriptor, &archive, &policies, |_| Ok(()), |_| Ok(())).unwrap(); + std::fs::write(&archive, b"different source").unwrap(); + std::fs::write(policies.join(GROUP_FILE), b"changed policy").unwrap(); + assert!( + input.reverify_bytes().is_ok(), + "held bytes outlive mutable source paths" + ); + assert_eq!(input.group(), Some(POLICY)); + let mut reader = input.snapshot.reopen().unwrap(); + let mut contents = Vec::new(); + reader.read_to_end(&mut contents).unwrap(); + assert_eq!(contents, ARCHIVE); + std::fs::write(input.snapshot.path(), b"changed snapshot").unwrap(); + assert!(matches!( + input.reverify_bytes(), + Err(ApplicationComponentInputError::Size | ApplicationComponentInputError::Digest) + )); +} diff --git a/crates/mvm-core/src/registry_pack/component_layer.rs b/crates/mvm-core/src/registry_pack/component_layer.rs new file mode 100644 index 0000000000..f191511dfe --- /dev/null +++ b/crates/mvm-core/src/registry_pack/component_layer.rs @@ -0,0 +1,641 @@ +//! Strict signed runtime and tool components, without legacy-layer fallback. +//! +//! A descriptor authenticates immutable bytes and compatibility requirements. +//! It does not prove a selected base satisfies those requirements or grant +//! permission to extract an archive or apply policy. + +use std::collections::{BTreeMap, BTreeSet}; +use std::io::Read; +use std::path::Path; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use super::application::{deserialize_object, validate_application_source}; +use super::application_component::{ + ComponentArchive, ComponentRequirements, validate_component_requirements, +}; +use super::{ + MAX_REGISTRY_DESCRIPTOR_BYTES, PackAdoption, PackLockfile, PackPin, PackReference, + RegistryPackFile, RegistryPackPublisherPolicy, RegistryPackSignatureChecker, + RegistryPackVerification, RegistryPackVerificationError, +}; +use crate::crypto::image_verify::VerifiedSigner; +use crate::packs::Sha256Hex; +use crate::plan::bundle::HashingWriter; +use crate::registry_pack_store::RegistryPackStoreError; + +/// Outer schema for strictly versioned, nonbootable runtime and tool components. +pub const COMPONENT_LAYER_MANIFEST_SCHEMA_VERSION: u32 = 4; +const MAX_COMPONENT_BYTES: u64 = 2 * 1024 * 1024 * 1024; +const MAX_DEPENDENCIES: usize = 16; +const MAX_RESOLVED_COMPONENTS: usize = 64; +const GROUP_FILE: &str = "pack/group.toml"; +const PROFILE_FILE: &str = "pack/profile.toml"; + +/// The only two component-layer roles. A schema 3 runtime layer is not one. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ComponentLayerKind { + Runtime, + Tool, +} + +/// One architecture's signed compatibility requirements and immutable inputs. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ComponentLayer { + pub schema_version: u32, + pub guest_arch: String, + #[serde(deserialize_with = "deserialize_object")] + pub requirements: ComponentRequirements, + pub dependencies: Vec, + #[serde(deserialize_with = "deserialize_object")] + pub archive: ComponentArchive, +} + +/// Closed signed wire shape; the kind determines the sole permitted nested field. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum RegistryComponentLayerManifest { + RuntimeComponent { + schema_version: u32, + reference: PackReference, + description: String, + #[serde(deserialize_with = "deserialize_object")] + runtime_component: ComponentLayer, + files: Vec, + }, + ToolComponent { + schema_version: u32, + reference: PackReference, + description: String, + #[serde(deserialize_with = "deserialize_object")] + tool_component: ComponentLayer, + files: Vec, + }, +} + +impl RegistryComponentLayerManifest { + /// Runtime or tool role selected by the exact signed kind. + pub fn kind(&self) -> ComponentLayerKind { + match self { + Self::RuntimeComponent { .. } => ComponentLayerKind::Runtime, + Self::ToolComponent { .. } => ComponentLayerKind::Tool, + } + } + + /// Exact versioned pack identity. + pub fn reference(&self) -> &PackReference { + match self { + Self::RuntimeComponent { reference, .. } | Self::ToolComponent { reference, .. } => { + reference + } + } + } + + /// Signed requirements, ordered dependencies and archive bytes. + pub fn component(&self) -> &ComponentLayer { + match self { + Self::RuntimeComponent { + runtime_component, .. + } => runtime_component, + Self::ToolComponent { tool_component, .. } => tool_component, + } + } + + /// Signed policy inventory, not authorization to apply its contents. + pub fn files(&self) -> &[RegistryPackFile] { + match self { + Self::RuntimeComponent { files, .. } | Self::ToolComponent { files, .. } => files, + } + } +} + +fn invalid(reason: &str) -> RegistryPackVerificationError { + RegistryPackVerificationError::ManifestParse(reason.to_string()) +} + +/// Parse only schema 4 runtime/tool component descriptors with closed shapes. +/// +/// # Errors +/// +/// Refuses oversized, unknown, duplicate, hybrid, unversioned or noncanonical data. +/// No publisher, external archive or policy bytes are authenticated by parsing. +pub fn parse_component_layer_manifest( + bytes: &[u8], +) -> Result { + if bytes.len() > MAX_REGISTRY_DESCRIPTOR_BYTES { + return Err(invalid("component layer descriptor exceeds 1 MiB")); + } + let mut deserializer = serde_json::Deserializer::from_slice(bytes); + let manifest: RegistryComponentLayerManifest = deserialize_object(&mut deserializer) + .map_err(|_| invalid("component layer descriptor has invalid or non-closed JSON"))?; + deserializer + .end() + .map_err(|_| invalid("component layer descriptor has trailing data"))?; + let (schema_version, description) = match &manifest { + RegistryComponentLayerManifest::RuntimeComponent { + schema_version, + description, + .. + } + | RegistryComponentLayerManifest::ToolComponent { + schema_version, + description, + .. + } => (*schema_version, description), + }; + let component = manifest.component(); + let reference = manifest.reference(); + if schema_version != COMPONENT_LAYER_MANIFEST_SCHEMA_VERSION || component.schema_version != 1 { + return Err(invalid( + "component layer requires outer schema 4 and nested schema 1", + )); + } + let namespace = match manifest.kind() { + ComponentLayerKind::Runtime => "runtime", + ComponentLayerKind::Tool => "tool", + }; + if reference.namespace() != namespace + || reference + .version() + .is_none_or(|version| version.as_str().contains('+')) + || description.trim().is_empty() + { + return Err(invalid( + "component layer requires a matching, versioned identity and description", + )); + } + if !matches!(component.guest_arch.as_str(), "aarch64" | "x86_64") { + return Err(invalid( + "component layer guest architecture is not canonical", + )); + } + validate_component_requirements(&component.requirements)?; + if component.archive.archive_size == 0 || component.archive.archive_size > MAX_COMPONENT_BYTES { + return Err(invalid("component layer archive size is out of bounds")); + } + validate_application_source(&component.archive.source)?; + if component.dependencies.len() > MAX_DEPENDENCIES { + return Err(invalid("component layer declares too many dependencies")); + } + let mut dependencies = BTreeSet::new(); + for dependency in &component.dependencies { + let reference = dependency.reference(); + if !matches!(reference.namespace(), "runtime" | "tool") + || reference + .version() + .is_none_or(|version| version.as_str().contains('+')) + || reference.coordinate_string() == manifest.reference().coordinate_string() + || !dependencies.insert(reference.coordinate_string()) + { + return Err(invalid( + "component layer has a duplicate, self or non-component dependency", + )); + } + } + let mut paths = BTreeSet::new(); + if manifest.files().is_empty() + || manifest.files().len() > 2 + || manifest.files().iter().any(|file| { + !matches!(file.path.as_str(), GROUP_FILE | PROFILE_FILE) + || !paths.insert(file.path.as_str()) + || file.size == 0 + || file.size > MAX_REGISTRY_DESCRIPTOR_BYTES as u64 + }) + { + return Err(invalid( + "component layer requires one or two pinned policy documents", + )); + } + Ok(manifest) +} + +/// Publisher-authenticated component metadata, not an installed or executable pack. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedRegistryComponentLayer { + manifest: RegistryComponentLayerManifest, + manifest_bytes: Vec, + signature_bundle: Vec, + manifest_sha256: Sha256Hex, + signer: VerifiedSigner, +} + +impl VerifiedRegistryComponentLayer { + /// Strictly parsed signed descriptor. + pub fn manifest(&self) -> &RegistryComponentLayerManifest { + &self.manifest + } + + /// Exact publisher-signed bytes, without reserialization. + pub fn manifest_bytes(&self) -> &[u8] { + &self.manifest_bytes + } + + /// Detached signature evidence. + pub fn signature_bundle(&self) -> &[u8] { + &self.signature_bundle + } + + /// Digest of the exact original signed descriptor bytes. + pub fn manifest_sha256(&self) -> &Sha256Hex { + &self.manifest_sha256 + } + + /// Publisher identity verified under the namespace's enrolled authority. + pub fn signer(&self) -> &VerifiedSigner { + &self.signer + } + + /// Require a current authenticated feed, with no legacy absent-feed fallback. + /// + /// # Errors + /// + /// Refuses revoked identities or manifests, expired feeds or missing trust. + pub fn check_current_revocations(&self) -> Result<(), RegistryPackStoreError> { + crate::registry_pack_store::check_application_revocations(self) + } + + /// Recheck signed evidence against the current publisher enrollment. + /// + /// # Errors + /// + /// Refuses withdrawn or changed publisher authority. + pub fn reverify_publisher(&self) -> Result<(), ComponentLayerInputError> { + let loaded = crate::registry_pack_store::load_publisher_policy_or_official_default( + &crate::config::registry_pack_publisher_policy_path(), + )?; + self.reverify_publisher_with(&loaded.policy, super::check_registry_pack_signature) + } + + fn reverify_publisher_with( + &self, + policy: &RegistryPackPublisherPolicy, + check: RegistryPackSignatureChecker, + ) -> Result<(), ComponentLayerInputError> { + let pin = PackPin::new( + self.manifest.reference().clone(), + self.manifest_sha256.clone(), + )?; + let lock = PackLockfile::new(vec![pin])?; + let request = RegistryPackVerification::new( + self.manifest.reference(), + &self.manifest_bytes, + &self.signature_bundle, + &lock, + policy, + ); + if verify_component_layer_with(&request, check)? != *self { + return Err(invalid("retained component layer publisher identity changed").into()); + } + Ok(()) + } +} + +/// Verify the exact lock pin, enrolled namespace signer and current revocations. +/// +/// # Errors +/// +/// Refuses drifted pins, untrusted signatures, invalid descriptors or revoked +/// metadata. No archive is acquired and no base compatibility is asserted. +pub fn verify_component_layer_manifest( + request: &RegistryPackVerification<'_>, +) -> Result { + let verified = verify_component_layer_with(request, super::check_registry_pack_signature)?; + verified.check_current_revocations()?; + Ok(verified) +} + +fn verify_component_layer_with( + request: &RegistryPackVerification<'_>, + check: RegistryPackSignatureChecker, +) -> Result { + match super::dispatch::verify_with(request, check)? { + super::VerifiedRegistryManifest::ComponentLayer(component) => Ok(component), + _ => Err(invalid("pinned pack is not a runtime or tool component")), + } +} + +/// Complete the typed component branch after dispatch verifies the publisher. +pub(super) fn finish_component_layer( + request: &PackAdoption<'_>, + signer: VerifiedSigner, +) -> Result { + let manifest = parse_component_layer_manifest(request.manifest_bytes)?; + let reference = manifest.reference(); + if reference.coordinate != request.requested.coordinate + || request + .requested + .version + .as_ref() + .is_some_and(|version| Some(version) != reference.version.as_ref()) + { + return Err(RegistryPackVerificationError::ManifestReferenceMismatch { + manifest: reference.to_string(), + locked: request.requested.to_string(), + }); + } + let manifest_sha256 = Sha256Hex::from_bytes(request.manifest_bytes); + super::historical::check_legacy_manifest_policy( + request.publisher_policy, + &signer.identity, + reference, + &manifest_sha256, + )?; + Ok(VerifiedRegistryComponentLayer { + manifest, + manifest_sha256, + signer, + manifest_bytes: request.manifest_bytes.to_vec(), + signature_bundle: request.signature_bundle.to_vec(), + }) +} + +/// Failure to resolve exact signed runtime/tool dependencies. +#[derive(Debug, Error)] +pub enum ComponentLayerResolveError { + #[error("component graph exceeds its bound or contains duplicate names")] + InvalidGraph, + #[error("component dependency is missing: {0}")] + Missing(String), + #[error("component dependency pin or architecture differs: {0}")] + Drift(String), + #[error("component dependency cycle at {0}")] + Cycle(String), + #[error(transparent)] + Revocation(#[from] RegistryPackStoreError), +} + +/// Resolve exact signed pins into dependency-first order, checking revocations. +/// +/// This accepts only schema 4 component metadata and cannot silently upgrade +/// schema 3 runtime layers. The returned metadata is not base compatibility or +/// permission to unpack an archive or apply policy. +/// +/// # Errors +/// +/// Refuses absent, drifted, duplicate, cyclic, wrong-architecture or revoked +/// components. Every selected manifest needs a current signed revocation feed. +pub fn resolve_component_layers<'a>( + roots: &[PackPin], + candidates: &'a [VerifiedRegistryComponentLayer], + arch: &str, +) -> Result, ComponentLayerResolveError> { + let ordered = resolve_graph(roots, candidates, arch)?; + for component in &ordered { + component.check_current_revocations()?; + } + Ok(ordered) +} + +fn resolve_graph<'a>( + roots: &[PackPin], + candidates: &'a [VerifiedRegistryComponentLayer], + arch: &str, +) -> Result, ComponentLayerResolveError> { + if !matches!(arch, "x86_64" | "aarch64") + || roots.len() > MAX_RESOLVED_COMPONENTS + || candidates.len() > MAX_RESOLVED_COMPONENTS + { + return Err(ComponentLayerResolveError::InvalidGraph); + } + let mut by_name = BTreeMap::new(); + for component in candidates { + if by_name + .insert( + component.manifest().reference().coordinate_string(), + component, + ) + .is_some() + { + return Err(ComponentLayerResolveError::InvalidGraph); + } + } + let mut visiting = BTreeSet::new(); + let mut visited = BTreeSet::new(); + let mut root_names = BTreeSet::new(); + let mut ordered = Vec::new(); + for root in roots { + if !root_names.insert(root.reference().coordinate_string()) { + return Err(ComponentLayerResolveError::InvalidGraph); + } + visit( + root, + arch, + &by_name, + &mut visiting, + &mut visited, + &mut ordered, + )?; + } + Ok(ordered) +} + +fn visit<'a>( + pin: &PackPin, + arch: &str, + by_name: &BTreeMap, + visiting: &mut BTreeSet, + visited: &mut BTreeSet, + ordered: &mut Vec<&'a VerifiedRegistryComponentLayer>, +) -> Result<(), ComponentLayerResolveError> { + let name = pin.reference().coordinate_string(); + let component = by_name + .get(&name) + .ok_or_else(|| ComponentLayerResolveError::Missing(pin.reference().to_string()))?; + if component.manifest().reference() != pin.reference() + || component.manifest_sha256() != pin.manifest_sha256() + || component.manifest().component().guest_arch != arch + { + return Err(ComponentLayerResolveError::Drift( + pin.reference().to_string(), + )); + } + if visiting.contains(&name) { + return Err(ComponentLayerResolveError::Cycle(name)); + } + if visited.contains(&name) { + return Ok(()); + } + visiting.insert(name.clone()); + for dependency in &component.manifest().component().dependencies { + visit(dependency, arch, by_name, visiting, visited, ordered)?; + } + visiting.remove(&name); + visited.insert(name); + ordered.push(component); + Ok(()) +} + +/// Failure to authenticate or hold a complete component input. +#[derive(Debug, Error)] +pub enum ComponentLayerInputError { + #[error(transparent)] + Verification(#[from] RegistryPackVerificationError), + #[error(transparent)] + Trust(#[from] RegistryPackStoreError), + #[error(transparent)] + Lock(#[from] super::RegistryPackError), + #[error("component layer input I/O failed: {0}")] + Io(#[from] std::io::Error), + #[error("component layer archive is not a regular file matching its signed size")] + Size, + #[error("component layer bytes differ from their signed descriptor")] + Digest, +} + +/// A private snapshot and signed policies, not an extracted or admitted layer. +#[derive(Debug)] +pub struct VerifiedComponentLayerInputs { + descriptor: VerifiedRegistryComponentLayer, + snapshot: tempfile::NamedTempFile, + group: Option>, + profile: Option>, +} + +impl VerifiedComponentLayerInputs { + /// Authenticated typed metadata, including requirements and ordered pins. + pub fn descriptor(&self) -> &VerifiedRegistryComponentLayer { + &self.descriptor + } + + /// Exact signed group policy bytes, if declared. + pub fn group(&self) -> Option<&[u8]> { + self.group.as_deref() + } + + /// Exact signed profile policy bytes, if declared. + pub fn profile(&self) -> Option<&[u8]> { + self.profile.as_deref() + } + + /// Recheck publisher, revocations, archive and held policy bytes. + /// + /// # Errors + /// + /// Refuses withdrawn trust or changed held bytes. + pub fn reverify(&self) -> Result<(), ComponentLayerInputError> { + self.descriptor.reverify_publisher()?; + self.descriptor.check_current_revocations()?; + self.reverify_bytes() + } + + fn reverify_bytes(&self) -> Result<(), ComponentLayerInputError> { + let expected = &self.descriptor.manifest.component().archive; + let reader = self.snapshot.reopen()?; + if reader.metadata()?.len() != expected.archive_size { + return Err(ComponentLayerInputError::Size); + } + let mut writer = HashingWriter::new(std::io::sink()); + std::io::copy(&mut reader.take(expected.archive_size + 1), &mut writer)?; + let (size, digest) = writer.finish(); + if size != expected.archive_size || digest != expected.archive_sha256.as_str() { + return Err(ComponentLayerInputError::Digest); + } + for file in self.descriptor.manifest.files() { + let bytes = match file.path.as_str() { + GROUP_FILE => self.group(), + PROFILE_FILE => self.profile(), + _ => None, + } + .ok_or(ComponentLayerInputError::Digest)?; + if bytes.len() as u64 != file.size || Sha256Hex::from_bytes(bytes) != file.sha256 { + return Err(ComponentLayerInputError::Digest); + } + } + Ok(()) + } + + /// Open the private archive after rechecking trust and exact byte identity. + /// + /// # Errors + /// + /// Refuses changed bytes, publisher policy or revocation feed. + pub fn open_verified_archive(&self) -> Result { + self.reverify()?; + Ok(std::fs::File::open(self.snapshot.path())?) + } +} + +/// Snapshot exact external archive bytes and every signed policy document. +/// +/// # Errors +/// +/// Refuses missing, linked, special, changed, oversized or mismatched inputs. +/// This does not verify archive entries or prove selected-base compatibility. +pub fn verify_component_layer_inputs( + descriptor: &VerifiedRegistryComponentLayer, + archive: &Path, + files_root: &Path, +) -> Result { + verify_inputs_with( + descriptor, + archive, + files_root, + VerifiedRegistryComponentLayer::reverify_publisher, + |descriptor| descriptor.check_current_revocations().map_err(Into::into), + ) +} + +fn verify_inputs_with( + descriptor: &VerifiedRegistryComponentLayer, + archive: &Path, + files_root: &Path, + publisher: impl FnOnce(&VerifiedRegistryComponentLayer) -> Result<(), ComponentLayerInputError>, + revocations: impl FnOnce(&VerifiedRegistryComponentLayer) -> Result<(), ComponentLayerInputError>, +) -> Result { + publisher(descriptor)?; + revocations(descriptor)?; + let expected = &descriptor.manifest.component().archive; + let input = super::local_input::open_sized_regular(archive, expected.archive_size).map_err( + |error| { + if error.kind() == std::io::ErrorKind::Other { + ComponentLayerInputError::Size + } else { + ComponentLayerInputError::Io(error) + } + }, + )?; + let mut snapshot = tempfile::NamedTempFile::new()?; + let mut writer = HashingWriter::new(snapshot.as_file_mut()); + std::io::copy(&mut input.take(expected.archive_size + 1), &mut writer)?; + let (size, digest) = writer.finish(); + if size != expected.archive_size { + return Err(ComponentLayerInputError::Size); + } + if digest != expected.archive_sha256.as_str() { + return Err(ComponentLayerInputError::Digest); + } + snapshot.as_file().sync_all()?; + let mut group = None; + let mut profile = None; + for file in descriptor.manifest.files() { + let mut source = super::local_input::open_payload(files_root, &file.path, file.size)?; + let before = source.metadata()?; + let mut bytes = Vec::new(); + source + .by_ref() + .take(file.size + 1) + .read_to_end(&mut bytes)?; + super::local_input::ensure_unchanged(&source, &before, files_root, &file.path, file.size)?; + if bytes.len() as u64 != file.size || Sha256Hex::from_bytes(&bytes) != file.sha256 { + return Err(ComponentLayerInputError::Digest); + } + match file.path.as_str() { + GROUP_FILE => group = Some(bytes), + PROFILE_FILE => profile = Some(bytes), + _ => return Err(ComponentLayerInputError::Digest), + } + } + let held = VerifiedComponentLayerInputs { + descriptor: descriptor.clone(), + snapshot, + group, + profile, + }; + held.reverify_bytes()?; + Ok(held) +} + +#[cfg(test)] +mod tests; diff --git a/crates/mvm-core/src/registry_pack/component_layer/tests.rs b/crates/mvm-core/src/registry_pack/component_layer/tests.rs new file mode 100644 index 0000000000..ce43c4d983 --- /dev/null +++ b/crates/mvm-core/src/registry_pack/component_layer/tests.rs @@ -0,0 +1,433 @@ +use serde_json::{Value, json}; + +use super::*; +use crate::registry_pack::tests::{accept_signature, publisher_policy, reject_signature}; +use crate::registry_pack::{ + OFFICIAL_PACK_SIGNING_IDENTITY, OFFICIAL_PACK_SIGNING_ISSUER, RegistryPackPublisher, +}; + +const ARCHIVE: &[u8] = b"component archive"; +const POLICY: &[u8] = b"required = true\n"; + +fn document(kind: ComponentLayerKind) -> Value { + let (kind_name, field, reference) = match kind { + ComponentLayerKind::Runtime => ( + "runtime_component", + "runtime_component", + "runtime/python@1.2.3", + ), + ComponentLayerKind::Tool => ("tool_component", "tool_component", "tool/git@1.2.3"), + }; + let mut value = json!({ + "schema_version": 4, + "kind": kind_name, + "reference": reference, + "description": "portable signed component", + "files": [{ + "path": "pack/group.toml", + "sha256": Sha256Hex::from_bytes(POLICY), + "size": POLICY.len() + }] + }); + value[field] = json!({ + "schema_version": 1, + "guest_arch": "aarch64", + "requirements": { + "filesystem_abi": 1, + "libc": "musl", + "loader": "/lib/ld-musl-aarch64.so.1", + "minimum_kernel": "6.1.0", + "required_capabilities": ["pty"] + }, + "dependencies": [{ + "reference": "runtime/base@1.0.0", + "manifest_sha256": Sha256Hex::from_bytes(b"base manifest") + }, { + "reference": "tool/helper@2.0.0", + "manifest_sha256": Sha256Hex::from_bytes(b"helper manifest") + }], + "archive": { + "source": "https://example.test/component.tar", + "archive_sha256": Sha256Hex::from_bytes(ARCHIVE), + "archive_size": ARCHIVE.len() + } + }); + value +} + +fn parse(value: &Value) -> Result { + parse_component_layer_manifest(&serde_json::to_vec(value).unwrap()) +} + +fn policy_for(reference: &PackReference) -> RegistryPackPublisherPolicy { + if reference.namespace() == "tool" { + RegistryPackPublisherPolicy::new(vec![ + RegistryPackPublisher::new( + "tool", + OFFICIAL_PACK_SIGNING_ISSUER, + vec![OFFICIAL_PACK_SIGNING_IDENTITY.to_owned()], + ) + .unwrap(), + ]) + .unwrap() + } else { + publisher_policy() + } +} + +fn authenticated(value: &Value) -> VerifiedRegistryComponentLayer { + let bytes = serde_json::to_vec(value).unwrap(); + let reference: PackReference = value["reference"].as_str().unwrap().parse().unwrap(); + let pin = PackPin::new(reference.clone(), Sha256Hex::from_bytes(&bytes)).unwrap(); + let lock = PackLockfile::new(vec![pin]).unwrap(); + let policy = policy_for(&reference); + verify_component_layer_with( + &RegistryPackVerification::new(&reference, &bytes, b"signed", &lock, &policy), + accept_signature, + ) + .unwrap() +} + +#[test] +fn both_kinds_have_closed_schema4_shapes_and_roundtrip() { + for kind in [ComponentLayerKind::Runtime, ComponentLayerKind::Tool] { + let value = document(kind); + let parsed = parse(&value).unwrap(); + assert_eq!(parsed.kind(), kind); + assert_eq!(parsed.component().dependencies.len(), 2); + assert_eq!(parsed.component().requirements.minimum_kernel, "6.1.0"); + assert_eq!( + parse(&serde_json::to_value(&parsed).unwrap()).unwrap(), + parsed + ); + let bytes = serde_json::to_vec(&value).unwrap(); + assert!(super::super::runtime_layer::parse_runtime_layer_manifest(&bytes).is_err()); + assert!( + super::super::application_component::parse_application_component_manifest(&bytes) + .is_err() + ); + let (field, other) = match kind { + ComponentLayerKind::Runtime => ("runtime_component", "tool_component"), + ComponentLayerKind::Tool => ("tool_component", "runtime_component"), + }; + for (path, replacement) in [ + (vec!["schema_version"], json!(3)), + (vec!["description"], json!(" ")), + (vec![field, "schema_version"], json!(2)), + (vec![field, "guest_arch"], json!("amd64")), + (vec![field, "archive", "archive_size"], json!(0)), + ( + vec![field, "archive", "source"], + json!("http://example.test/app.tar"), + ), + (vec![field, "requirements", "filesystem_abi"], json!(2)), + ( + vec![field, "requirements", "required_capabilities"], + json!(["pty", "pty"]), + ), + (vec![field, "requirements", "loader"], Value::Null), + ] { + let mut changed = value.clone(); + let mut slot = &mut changed; + for segment in path { + slot = &mut slot[segment]; + } + *slot = replacement; + assert!(parse(&changed).is_err(), "{changed}"); + } + let mut hybrid = value.clone(); + hybrid[other] = value[field].clone(); + assert!(parse(&hybrid).is_err()); + let mut unknown = value.clone(); + unknown[field]["role"] = json!("application"); + assert!(parse(&unknown).is_err()); + let mut null = value.clone(); + null[field] = Value::Null; + assert!(parse(&null).is_err()); + let mut duplicate = serde_json::to_string(&value).unwrap(); + duplicate = duplicate.replacen("\"kind\":", "\"kind\":\"runtime_component\",\"kind\":", 1); + assert!(parse_component_layer_manifest(duplicate.as_bytes()).is_err()); + } + assert!( + parse_component_layer_manifest(&vec![b' '; MAX_REGISTRY_DESCRIPTOR_BYTES + 1]).is_err() + ); +} + +#[test] +fn namespace_requirements_and_dependencies_are_exact() { + for kind in [ComponentLayerKind::Runtime, ComponentLayerKind::Tool] { + let value = document(kind); + let field = match kind { + ComponentLayerKind::Runtime => "runtime_component", + ComponentLayerKind::Tool => "tool_component", + }; + for bad in [ + "agent/worker@1.2.3", + "runtime/worker@1.2.3+build", + "tool/worker", + ] { + let mut changed = value.clone(); + changed["reference"] = json!(bad); + assert!(parse(&changed).is_err(), "{bad}"); + } + for bad in [ + "agent/worker@1.0.0", + "runtime/base", + "runtime/base@1.0.0+build", + value["reference"].as_str().unwrap(), + ] { + let mut changed = value.clone(); + changed[field]["dependencies"][0]["reference"] = json!(bad); + assert!(parse(&changed).is_err(), "{bad}"); + } + let mut duplicate = value.clone(); + duplicate[field]["dependencies"][1]["reference"] = json!("runtime/base@2.0.0"); + assert!(parse(&duplicate).is_err()); + let mut too_many = value.clone(); + too_many[field]["dependencies"] = json!(vec![value[field]["dependencies"][0].clone(); 17]); + assert!(parse(&too_many).is_err()); + let mut static_ok = value.clone(); + static_ok[field]["requirements"]["libc"] = json!("static"); + static_ok[field]["requirements"] + .as_object_mut() + .unwrap() + .remove("loader"); + assert!(parse(&static_ok).is_ok()); + } +} + +#[test] +fn files_only_name_signed_policy_documents() { + let base = document(ComponentLayerKind::Runtime); + for files in [ + json!([]), + json!([base["files"][0].clone(), base["files"][0].clone()]), + json!([{"path": "pack/other.toml", "sha256": Sha256Hex::from_bytes(POLICY), "size": POLICY.len()}]), + json!([{"path": "pack/group.toml", "sha256": Sha256Hex::from_bytes(POLICY), "size": 0}]), + ] { + let mut changed = base.clone(); + changed["files"] = files; + assert!(parse(&changed).is_err()); + } + let mut good = base; + good["files"].as_array_mut().unwrap().push(json!({ + "path": PROFILE_FILE, + "sha256": Sha256Hex::from_bytes(b"profile"), + "size": 7 + })); + assert!(parse(&good).is_ok()); +} + +#[test] +fn dependency_graph_requires_each_exact_signed_pin_in_dependency_first_order() { + let mut base = document(ComponentLayerKind::Runtime); + base["reference"] = json!("runtime/base@1.0.0"); + base["runtime_component"]["dependencies"] = json!([]); + let base = authenticated(&base); + let mut tool = document(ComponentLayerKind::Tool); + tool["reference"] = json!("tool/helper@2.0.0"); + tool["tool_component"]["dependencies"] = json!([]); + let tool = authenticated(&tool); + let mut root = document(ComponentLayerKind::Runtime); + root["runtime_component"]["dependencies"][0]["manifest_sha256"] = json!(base.manifest_sha256()); + root["runtime_component"]["dependencies"][1]["manifest_sha256"] = json!(tool.manifest_sha256()); + let root = authenticated(&root); + let roots = vec![ + PackPin::new( + root.manifest().reference().clone(), + root.manifest_sha256().clone(), + ) + .unwrap(), + ]; + let candidates = vec![root.clone(), base.clone(), tool.clone()]; + let ordered = resolve_graph(&roots, &candidates, "aarch64").unwrap(); + let names: Vec<_> = ordered + .iter() + .map(|component| component.manifest().reference().to_string()) + .collect(); + assert_eq!( + names, + [ + "runtime/base@1.0.0", + "tool/helper@2.0.0", + "runtime/python@1.2.3" + ] + ); + + assert!(matches!( + resolve_graph(&roots, &[root.clone(), base.clone()], "aarch64"), + Err(ComponentLayerResolveError::Missing(_)) + )); + assert!(matches!( + resolve_graph(&roots, &[root.clone(), base.clone(), base], "aarch64"), + Err(ComponentLayerResolveError::InvalidGraph) + )); + assert!(matches!( + resolve_graph( + &[roots[0].clone(), roots[0].clone()], + &candidates, + "aarch64" + ), + Err(ComponentLayerResolveError::InvalidGraph) + )); + assert!(matches!( + resolve_graph(&roots, &candidates, "x86_64"), + Err(ComponentLayerResolveError::Drift(_)) + )); + let wrong_pin = PackPin::new( + root.manifest().reference().clone(), + Sha256Hex::from_bytes(b"different"), + ) + .unwrap(); + assert!(matches!( + resolve_graph(&[wrong_pin], &candidates, "aarch64"), + Err(ComponentLayerResolveError::Drift(_)) + )); + let mut tampered = candidates; + if let RegistryComponentLayerManifest::RuntimeComponent { + runtime_component, .. + } = &mut tampered[0].manifest + { + runtime_component.dependencies[0] = PackPin::new( + tool.manifest().reference().clone(), + Sha256Hex::from_bytes(b"wrong dependency"), + ) + .unwrap(); + } + assert!(matches!( + resolve_graph(&roots, &tampered, "aarch64"), + Err(ComponentLayerResolveError::Drift(_)) + )); +} + +#[test] +fn publisher_and_lock_bind_original_bytes_not_reserialized_metadata() { + let value = document(ComponentLayerKind::Runtime); + let verified = authenticated(&value); + let bytes = serde_json::to_vec(&value).unwrap(); + assert_eq!(verified.manifest_bytes(), bytes); + assert_eq!(verified.signature_bundle(), b"signed"); + assert_eq!(verified.manifest_sha256(), &Sha256Hex::from_bytes(&bytes)); + let policy = publisher_policy(); + verified + .reverify_publisher_with(&policy, accept_signature) + .unwrap(); + assert!( + verified + .reverify_publisher_with( + &RegistryPackPublisherPolicy::new(vec![]).unwrap(), + accept_signature + ) + .is_err() + ); + let reference = verified.manifest().reference(); + let pin = PackPin::new(reference.clone(), verified.manifest_sha256().clone()).unwrap(); + let lock = PackLockfile::new(vec![pin]).unwrap(); + assert!( + verify_component_layer_with( + &RegistryPackVerification::new(reference, &bytes, b"unsigned", &lock, &policy), + reject_signature + ) + .is_err() + ); + let mut changed = bytes.clone(); + changed.push(b' '); + assert!( + verify_component_layer_with( + &RegistryPackVerification::new(reference, &changed, b"signed", &lock, &policy), + accept_signature + ) + .is_err() + ); + let other: PackReference = "runtime/other@1.2.3".parse().unwrap(); + assert!( + verify_component_layer_with( + &RegistryPackVerification::new(&other, &bytes, b"signed", &lock, &policy), + accept_signature + ) + .is_err() + ); +} + +#[test] +fn signed_current_feed_revokes_identity_digest_and_expired_document() { + use crate::packs::KeylessTrust; + use crate::registry_pack_revocation::{ + RegistryPackRevocationError, verify_registry_pack_revocations_with, + }; + + let verified = authenticated(&document(ComponentLayerKind::Tool)); + let now = chrono::Utc::now(); + let trust = KeylessTrust { + issuer: "test issuer".into(), + accepted_identities: vec!["test feed signer".into()], + }; + for (identities, digests, revoked) in [ + (Vec::new(), Vec::new(), false), + (vec![verified.signer().identity.clone()], Vec::new(), true), + (Vec::new(), vec![verified.manifest_sha256().clone()], true), + ] { + let document = json!({ + "schema_version": 1, + "sequence": 1, + "issued_at": now - chrono::Duration::hours(1), + "not_after": now + chrono::Duration::hours(1), + "revoked_identities": identities, + "revoked_manifests": digests, + }); + let feed = verify_registry_pack_revocations_with( + &serde_json::to_vec(&document).unwrap(), + b"signed feed", + &trust, + now, + None, + |_, _, _| Ok(()), + ) + .unwrap(); + assert_eq!( + feed.check_verified_pack_at(&verified, now).is_err(), + revoked + ); + assert!(matches!( + feed.check_verified_pack_at(&verified, now + chrono::Duration::hours(2)), + Err(RegistryPackRevocationError::Expired) + )); + } +} + +#[test] +fn private_inputs_pin_archive_and_policy_and_refuse_mutation() { + let verified = authenticated(&document(ComponentLayerKind::Runtime)); + let directory = tempfile::tempdir().unwrap(); + let archive = directory.path().join("archive.tar"); + let payload = directory.path().join("payload"); + std::fs::create_dir_all(payload.join("pack")).unwrap(); + std::fs::write(&archive, ARCHIVE).unwrap(); + std::fs::write(payload.join(GROUP_FILE), POLICY).unwrap(); + + std::fs::write(&archive, b"bad").unwrap(); + assert!(verify_inputs_with(&verified, &archive, &payload, |_| Ok(()), |_| Ok(())).is_err()); + std::fs::write(&archive, b"component archivE").unwrap(); + assert!(matches!( + verify_inputs_with(&verified, &archive, &payload, |_| Ok(()), |_| Ok(())), + Err(ComponentLayerInputError::Digest) + )); + std::fs::write(&archive, ARCHIVE).unwrap(); + std::fs::write(payload.join(GROUP_FILE), b"wrong policy...\n").unwrap(); + assert!(verify_inputs_with(&verified, &archive, &payload, |_| Ok(()), |_| Ok(())).is_err()); + std::fs::write(payload.join(GROUP_FILE), POLICY).unwrap(); + let held = verify_inputs_with(&verified, &archive, &payload, |_| Ok(()), |_| Ok(())).unwrap(); + std::fs::write(&archive, b"changed original").unwrap(); + std::fs::write(payload.join(GROUP_FILE), b"changed original").unwrap(); + assert!(held.reverify_bytes().is_ok()); + assert_eq!(held.group(), Some(POLICY)); + let mut snapshot = held.snapshot.reopen().unwrap(); + let mut bytes = Vec::new(); + snapshot.read_to_end(&mut bytes).unwrap(); + assert_eq!(bytes, ARCHIVE); + std::fs::write(held.snapshot.path(), b"tampered private").unwrap(); + assert!(matches!( + held.reverify_bytes(), + Err(ComponentLayerInputError::Digest | ComponentLayerInputError::Size) + )); +} diff --git a/crates/mvm-core/src/registry_pack/dispatch.rs b/crates/mvm-core/src/registry_pack/dispatch.rs index ad1d8ac017..4971f4a01f 100644 --- a/crates/mvm-core/src/registry_pack/dispatch.rs +++ b/crates/mvm-core/src/registry_pack/dispatch.rs @@ -1,6 +1,10 @@ //! Authenticated manifest dispatch. Bundle metadata is not bundle admission. use super::application::{RegistryApplicationManifest, parse_application_manifest}; +use super::application_component::{ + RegistryApplicationComponentManifest, parse_application_component_manifest, +}; +use super::component_layer::{VerifiedRegistryComponentLayer, finish_component_layer}; use super::runtime_layer::{RegistryRuntimeLayerManifest, parse_runtime_layer_manifest}; use super::*; @@ -76,10 +80,46 @@ impl VerifiedRegistryRuntimeLayer { } } +/// Publisher-authenticated component metadata, not an executable application. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedRegistryApplicationComponent { + pub(super) manifest: RegistryApplicationComponentManifest, + pub(super) manifest_bytes: Vec, + pub(super) signature_bundle: Vec, + pub(super) manifest_sha256: Sha256Hex, + pub(super) signer: VerifiedSigner, +} + +impl VerifiedRegistryApplicationComponent { + /// Strictly parsed, publisher-authenticated descriptor. + pub fn manifest(&self) -> &RegistryApplicationComponentManifest { + &self.manifest + } + + /// Exact signed descriptor bytes. + pub fn manifest_bytes(&self) -> &[u8] { + &self.manifest_bytes + } + + /// Detached publisher signature evidence. + pub fn signature_bundle(&self) -> &[u8] { + &self.signature_bundle + } + + /// Require the current signed registry revocation feed before use. + pub fn check_current_revocations( + &self, + ) -> Result<(), crate::registry_pack_store::RegistryPackStoreError> { + crate::registry_pack_store::check_application_revocations(self) + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum VerifiedRegistryManifest { Legacy(VerifiedRegistryPack), Application(VerifiedRegistryApplication), + ApplicationComponent(VerifiedRegistryApplicationComponent), + ComponentLayer(VerifiedRegistryComponentLayer), RuntimeLayer(VerifiedRegistryRuntimeLayer), } @@ -143,12 +183,46 @@ impl AuthenticatedRegistryManifest for VerifiedRegistryRuntimeLayer { } } +impl sealed::Sealed for VerifiedRegistryApplicationComponent {} +impl AuthenticatedRegistryManifest for VerifiedRegistryApplicationComponent { + fn reference(&self) -> &PackReference { + &self.manifest.reference + } + fn manifest_sha256(&self) -> &Sha256Hex { + &self.manifest_sha256 + } + fn signer(&self) -> &VerifiedSigner { + &self.signer + } + fn requires_operator_revocations(&self) -> bool { + true + } +} + +impl sealed::Sealed for VerifiedRegistryComponentLayer {} +impl AuthenticatedRegistryManifest for VerifiedRegistryComponentLayer { + fn reference(&self) -> &PackReference { + self.manifest().reference() + } + fn manifest_sha256(&self) -> &Sha256Hex { + self.manifest_sha256() + } + fn signer(&self) -> &VerifiedSigner { + self.signer() + } + fn requires_operator_revocations(&self) -> bool { + true + } +} + impl sealed::Sealed for VerifiedRegistryManifest {} impl AuthenticatedRegistryManifest for VerifiedRegistryManifest { fn reference(&self) -> &PackReference { match self { Self::Legacy(pack) => pack.reference(), Self::Application(application) => application.reference(), + Self::ApplicationComponent(component) => component.reference(), + Self::ComponentLayer(component) => component.reference(), Self::RuntimeLayer(layer) => layer.reference(), } } @@ -157,6 +231,8 @@ impl AuthenticatedRegistryManifest for VerifiedRegistryManifest { match self { Self::Legacy(pack) => pack.manifest_sha256(), Self::Application(application) => application.manifest_sha256(), + Self::ApplicationComponent(component) => component.manifest_sha256(), + Self::ComponentLayer(component) => component.manifest_sha256(), Self::RuntimeLayer(layer) => layer.manifest_sha256(), } } @@ -165,6 +241,8 @@ impl AuthenticatedRegistryManifest for VerifiedRegistryManifest { match self { Self::Legacy(pack) => pack.signer(), Self::Application(application) => application.signer(), + Self::ApplicationComponent(component) => component.signer(), + Self::ComponentLayer(component) => component.signer(), Self::RuntimeLayer(layer) => layer.signer(), } } @@ -173,6 +251,8 @@ impl AuthenticatedRegistryManifest for VerifiedRegistryManifest { match self { Self::Legacy(pack) => pack.requires_operator_revocations(), Self::Application(application) => application.requires_operator_revocations(), + Self::ApplicationComponent(component) => component.requires_operator_revocations(), + Self::ComponentLayer(component) => component.requires_operator_revocations(), Self::RuntimeLayer(layer) => layer.requires_operator_revocations(), } } @@ -203,6 +283,20 @@ pub(super) fn verify_with( ) } +/// Verify a component's pinned bytes and namespace publisher, without +/// admitting its archive or policy. The public entrypoint also checks revocations. +pub(super) fn verify_component_with( + request: &RegistryPackVerification<'_>, + check: RegistryPackSignatureChecker, +) -> Result { + match verify_with(request, check)? { + VerifiedRegistryManifest::ApplicationComponent(component) => Ok(component), + _ => Err(RegistryPackVerificationError::ManifestParse( + "pinned pack is not a portable application component".into(), + )), + } +} + /// First adoption authenticates the publisher; it does not persist a pin. pub fn adopt_registry_manifest( request: &PackAdoption<'_>, @@ -301,6 +395,40 @@ pub(super) fn adopt_with( }, )) } + (3, Some("application_component")) => { + let manifest = parse_application_component_manifest(request.manifest_bytes)?; + if manifest.reference.coordinate != request.requested.coordinate + || request + .requested + .version + .as_ref() + .is_some_and(|v| Some(v) != manifest.reference.version.as_ref()) + { + return Err(RegistryPackVerificationError::ManifestReferenceMismatch { + manifest: manifest.reference.to_string(), + locked: request.requested.to_string(), + }); + } + let manifest_sha256 = Sha256Hex::from_bytes(request.manifest_bytes); + historical::check_legacy_manifest_policy( + request.publisher_policy, + &signer.identity, + &manifest.reference, + &manifest_sha256, + )?; + Ok(VerifiedRegistryManifest::ApplicationComponent( + VerifiedRegistryApplicationComponent { + manifest, + manifest_sha256, + signer, + manifest_bytes: request.manifest_bytes.to_vec(), + signature_bundle: request.signature_bundle.to_vec(), + }, + )) + } + (4, Some("runtime_component" | "tool_component")) => { + finish_component_layer(request, signer).map(VerifiedRegistryManifest::ComponentLayer) + } _ => Err(RegistryPackVerificationError::ManifestParse( "unsupported registry manifest kind/schema pair".to_string(), )), diff --git a/crates/mvm-core/src/registry_pack_store.rs b/crates/mvm-core/src/registry_pack_store.rs index 49beb43328..b17d452a76 100644 --- a/crates/mvm-core/src/registry_pack_store.rs +++ b/crates/mvm-core/src/registry_pack_store.rs @@ -957,6 +957,20 @@ pub(crate) fn open_installed_registry_pack_with( } .into()) } + VerifiedRegistryManifest::ApplicationComponent(component) => { + Err(RegistryPackVerificationError::UnsupportedManifestSchema { + got: component.manifest().schema_version, + expected: crate::registry_pack::REGISTRY_PACK_MANIFEST_SCHEMA_VERSION, + } + .into()) + } + VerifiedRegistryManifest::ComponentLayer(_) => { + Err(RegistryPackVerificationError::UnsupportedManifestSchema { + got: 4, + expected: crate::registry_pack::REGISTRY_PACK_MANIFEST_SCHEMA_VERSION, + } + .into()) + } VerifiedRegistryManifest::RuntimeLayer(layer) => { Err(RegistryPackVerificationError::UnsupportedManifestSchema { got: layer.manifest().schema_version, @@ -1030,6 +1044,18 @@ fn open_installed_registry_manifest_with_checks( VerifiedRegistryManifest::Application(application) => { check_application(application)?; } + VerifiedRegistryManifest::ApplicationComponent(_) => { + return Err(RegistryPackVerificationError::ManifestParse( + "application component install requires verified archive custody".to_string(), + ) + .into()); + } + VerifiedRegistryManifest::ComponentLayer(_) => { + return Err(RegistryPackVerificationError::ManifestParse( + "runtime/tool component install requires verified archive custody".to_string(), + ) + .into()); + } VerifiedRegistryManifest::RuntimeLayer(_) => { return Err(RegistryPackVerificationError::ManifestParse( "runtime layer install requires verified archive custody".to_string(), diff --git a/crates/mvm-hostd/Cargo.toml b/crates/mvm-hostd/Cargo.toml index 9ae251bb2d..2ff91fd5b3 100644 --- a/crates/mvm-hostd/Cargo.toml +++ b/crates/mvm-hostd/Cargo.toml @@ -208,6 +208,7 @@ default = [] # macOS-only protected caller opt-in; other platforms return Unsupported. native-caller-identity = ["dep:core-foundation", "dep:security-framework"] manifest-verify = ["mvm-core/manifest-verify"] +pr-test-guest-runtime = ["mvm-runtime/pr-test-guest-runtime", "mvm-build/pr-test-guest-runtime"] # Build the hermetic legacy-L3/FlowMux comparison producer. Never enabled by # a production binary; it exposes only a high-level loopback fixture. network-perf = [] diff --git a/crates/mvm-hostd/src/operator_admitted_trial_booter.rs b/crates/mvm-hostd/src/operator_admitted_trial_booter.rs index e9354b320f..b7737de756 100644 --- a/crates/mvm-hostd/src/operator_admitted_trial_booter.rs +++ b/crates/mvm-hostd/src/operator_admitted_trial_booter.rs @@ -664,6 +664,10 @@ pub(crate) fn verify_assurance_guest_protocol( Some(mvm_runtime::runtime_evidence::VerifiedLaunchRuntime::Source(_)) => { anyhow::bail!("assurance requires a released runtime, not a source runtime"); } + #[cfg(feature = "pr-test-guest-runtime")] + Some(mvm_runtime::runtime_evidence::VerifiedLaunchRuntime::PrTest(_)) => { + anyhow::bail!("assurance refuses PR test runtime provenance"); + } Some(mvm_runtime::runtime_evidence::VerifiedLaunchRuntime::LegacyBundle(_)) | Some(mvm_runtime::runtime_evidence::VerifiedLaunchRuntime::LegacyPortable(_)) => Ok(None), None => anyhow::bail!("assurance requires authenticated guest runtime provenance"), diff --git a/crates/mvm-hostd/src/plan_admission.rs b/crates/mvm-hostd/src/plan_admission.rs index 788f1ccd38..1df63977e3 100644 --- a/crates/mvm-hostd/src/plan_admission.rs +++ b/crates/mvm-hostd/src/plan_admission.rs @@ -97,6 +97,7 @@ mod composition; mod display_grants; mod final_gates; mod host_gates; +mod local_development; mod nonce_ledger; mod registry_pack; mod verb_grant_sidecar; @@ -566,6 +567,18 @@ fn finish_admission(parts: VerifiedAdmission<'_>) -> Result { .context("canonical kernel admission requires actual backend")?, &mvm_build::stage0_kernel::current_image_set_protocol_support(), ) + } else if posture.variant == Variant::Dev { + if let Some(host_key) = mvm_core::plan::bundle::existing_local_build_key()? { + mvm_core::plan::bundle::verify_development_plan_bundle( + pin, + ctx.resolver, + ctx.trust, + &mvm_core::plan::bundle::key_id_from_pubkey(&host_key), + config, + ) + } else { + verify_plan_bundle(pin, ctx.resolver, ctx.trust) + } } else { verify_plan_bundle(pin, ctx.resolver, ctx.trust) } @@ -573,6 +586,7 @@ fn finish_admission(parts: VerifiedAdmission<'_>) -> Result { verify_plan_bundle(pin, ctx.resolver, ctx.trust) } .with_context(|| format!("bundle re-verify for pin {}", pin.bundle_sha256))?; + local_development::verify(&verified, &signed, &signer_id, &posture, &verified_bundle)?; bundle_environment::verify_environment(&verified, &verified_bundle.manifest, posture)?; composition::verify(&verified, &verified_bundle)?; if !verified_bundle.embedded_image_sets.is_empty() { diff --git a/crates/mvm-hostd/src/plan_admission/assurance_runtime.rs b/crates/mvm-hostd/src/plan_admission/assurance_runtime.rs index f2a5257cf6..9f2b5d382e 100644 --- a/crates/mvm-hostd/src/plan_admission/assurance_runtime.rs +++ b/crates/mvm-hostd/src/plan_admission/assurance_runtime.rs @@ -115,6 +115,11 @@ impl RunPosture<'_> { let Some(config) = self.runtime_config else { return Ok(()); }; + #[cfg(feature = "pr-test-guest-runtime")] + anyhow::ensure!( + config.pr_test_runtime_proof.is_none() || self.variant == super::Variant::Dev, + "PR test runtime is not admitted under production posture" + ); verify_selected_runtime(config) } @@ -146,6 +151,33 @@ mod tests { use super::super::{BackendKind, Variant}; use super::*; + #[cfg(feature = "pr-test-guest-runtime")] + #[test] + fn production_posture_refuses_pr_test_runtime_before_archive_access() { + let config = VmStartConfig { + pr_test_runtime_proof: Some(mvm_core::protocol::runtime_release::PrTestRuntimeProof { + input: mvm_core::protocol::runtime_release::RuntimeReleaseProof { + archive: "/nonexistent/pr/archive".into(), + checksum: "/nonexistent/pr/checksum".into(), + signature_bundle: "/nonexistent/pr/bundle".into(), + archive_sha256: "a".repeat(64), + version: env!("CARGO_PKG_VERSION").into(), + arch: mvm_core::arch::GuestArch::host(), + }, + pull_request: 42, + }), + ..Default::default() + }; + let error = RunPosture::on_backend(Variant::Prod, BackendKind::Firecracker) + .with_runtime(Some(&config)) + .verify_runtime() + .unwrap_err(); + assert!( + error.to_string().contains("production posture"), + "production refusal must precede archive access: {error:#}" + ); + } + #[test] fn runtime_gate_refuses_unverified_unsigned_request_with_forged_plan() { let config = VmStartConfig { diff --git a/crates/mvm-hostd/src/plan_admission/composition.rs b/crates/mvm-hostd/src/plan_admission/composition.rs index 9a2cc50980..7dd41a6b7f 100644 --- a/crates/mvm-hostd/src/plan_admission/composition.rs +++ b/crates/mvm-hostd/src/plan_admission/composition.rs @@ -62,9 +62,12 @@ pub(super) fn verify(plan: &ExecutionPlan, bundle: &VerifiedBundle) -> Result<() .iter() .find_map(|member| match member { BundleMember::CanonicalLinuxProof(proof) => Some(proof.manifest_artifact.as_str()), + BundleMember::LocalDevelopmentBaseProof(proof) => { + Some(proof.manifest_artifact.as_str()) + } _ => None, }) - .context("composition lacks original signed Linux root")?; + .context("composition lacks selected Linux base evidence")?; let evidence = |name: &str| -> Result<&[u8]> { let artifact = bundle .manifest diff --git a/crates/mvm-hostd/src/plan_admission/local_development.rs b/crates/mvm-hostd/src/plan_admission/local_development.rs new file mode 100644 index 0000000000..b784b5dbe0 --- /dev/null +++ b/crates/mvm-hostd/src/plan_admission/local_development.rs @@ -0,0 +1,46 @@ +//! Same-host custody for a selected development base; never release authority. + +use anyhow::{Context, Result}; +use mvm_core::plan::bundle::{ArtifactRole, BundleMember, VerifiedBundle, key_id_from_pubkey}; +use mvm_core::plan::{ExecutionPlan, SignedExecutionPlan, Variant, verify_plan}; + +use super::{RunPosture, host_signer_id}; + +pub(super) fn verify( + plan: &ExecutionPlan, + signed: &SignedExecutionPlan, + signer_id: &str, + posture: &RunPosture<'_>, + bundle: &VerifiedBundle, +) -> Result<()> { + if !bundle + .manifest + .members + .iter() + .any(|member| matches!(member, BundleMember::LocalDevelopmentBaseProof(_))) + { + return Ok(()); + } + anyhow::ensure!( + posture.variant == Variant::Dev && signer_id == host_signer_id(), + "local development base refuses production or externally signed plans" + ); + let host = crate::audit::host_keypair::load_or_init()?; + verify_plan(signed, &[(signer_id, &host.verifying)]) + .context("local development plan must be signed on this host")?; + anyhow::ensure!( + bundle.key_id == key_id_from_pubkey(&host.verifying), + "local development bundle signer differs from this host" + ); + let kernel = bundle + .manifest + .find_by_role(&ArtifactRole::Kernel) + .context("local development kernel is missing")?; + anyhow::ensure!( + plan.environment + .as_ref() + .is_some_and(|environment| environment.kernel_sha256 == kernel.sha256), + "local development kernel differs from signed plan environment" + ); + Ok(()) +} diff --git a/crates/mvm-hostd/src/plan_admission/registry_pack.rs b/crates/mvm-hostd/src/plan_admission/registry_pack.rs index e4bb5d8101..2f2453f96f 100644 --- a/crates/mvm-hostd/src/plan_admission/registry_pack.rs +++ b/crates/mvm-hostd/src/plan_admission/registry_pack.rs @@ -300,6 +300,12 @@ fn finalize_registry_images( VerifiedRegistryManifest::RuntimeLayer(_) => { anyhow::bail!("a runtime layer cannot be used as a boot image"); } + VerifiedRegistryManifest::ApplicationComponent(_) => { + anyhow::bail!("an application component cannot be used as a boot image"); + } + VerifiedRegistryManifest::ComponentLayer(_) => { + anyhow::bail!("a runtime/tool component cannot be used as a boot image"); + } VerifiedRegistryManifest::Application(_) => { anyhow::ensure!( !built, diff --git a/crates/mvm-hostd/src/run.rs b/crates/mvm-hostd/src/run.rs index bc14128b65..52e3bae50a 100644 --- a/crates/mvm-hostd/src/run.rs +++ b/crates/mvm-hostd/src/run.rs @@ -190,6 +190,14 @@ pub(crate) fn attach_guest_runtime(config: &mut VmStartConfig, backend_name: &st if !mvm_runtime::universal_initramfs::KERNEL_BOOTING_HYPERVISORS.contains(&backend_name) { return Ok(()); } + #[cfg(feature = "pr-test-guest-runtime")] + if config.pr_test_runtime_proof.is_some() { + mvm_runtime::runtime_evidence::verify_runtime_for_launch( + config, + mvm_runtime::runtime_evidence::MIN_GUEST_RPC_PROTOCOL_VERSION, + )?; + return Ok(()); + } let mut selected = config.clone(); attach_runtime_overlay_from_cache(&mut selected, backend_name)?; let cache = PathBuf::from(mvm_core::config::mvm_cache_dir()); diff --git a/crates/mvm-hostd/src/supervisor/caller_registration.rs b/crates/mvm-hostd/src/supervisor/caller_registration.rs index 9110c43edc..9a48c07b11 100644 --- a/crates/mvm-hostd/src/supervisor/caller_registration.rs +++ b/crates/mvm-hostd/src/supervisor/caller_registration.rs @@ -603,6 +603,12 @@ fn reopen_caller_image( VerifiedRegistryManifest::RuntimeLayer(_) => { anyhow::bail!("a runtime layer cannot be used as a caller boot image"); } + VerifiedRegistryManifest::ApplicationComponent(_) => { + anyhow::bail!("an application component cannot be used as a caller boot image"); + } + VerifiedRegistryManifest::ComponentLayer(_) => { + anyhow::bail!("a runtime/tool component cannot be used as a caller boot image"); + } VerifiedRegistryManifest::Application(_) => { use mvm_core::plan::bundle::{ ArtifactRole, BundleRegistry, FsBundleResolver, FsTrustStore, diff --git a/crates/mvm-runtime/Cargo.toml b/crates/mvm-runtime/Cargo.toml index 448a4836ac..193b9619fd 100644 --- a/crates/mvm-runtime/Cargo.toml +++ b/crates/mvm-runtime/Cargo.toml @@ -109,6 +109,7 @@ nix = { version = "0.29", features = ["mount", "sched"] } [features] default = [] manifest-verify = ["mvm-core/manifest-verify"] +pr-test-guest-runtime = ["mvm-build/pr-test-guest-runtime", "mvm-core/pr-test-guest-runtime"] template-registry-s3 = ["dep:object_store"] # The claim-free host-wasmtime portability tier (`WasmBackend`). Off by # default; pulls the wasmtime + wasmtime-wasi closure only when enabled. diff --git a/crates/mvm-runtime/src/mock.rs b/crates/mvm-runtime/src/mock.rs index f952965682..889d9ad8e0 100644 --- a/crates/mvm-runtime/src/mock.rs +++ b/crates/mvm-runtime/src/mock.rs @@ -507,6 +507,8 @@ mod tests { runtime_overlay_roothash: None, runtime_overlay_version: None, runtime_release_proof: None, + #[cfg(feature = "pr-test-guest-runtime")] + pr_test_runtime_proof: None, legacy_bundle_runtime: None, legacy_portable_runtime: None, source_runtime_archive_sha256: None, diff --git a/crates/mvm-runtime/src/runtime_evidence.rs b/crates/mvm-runtime/src/runtime_evidence.rs index 0bda91cde5..01b5cebe99 100644 --- a/crates/mvm-runtime/src/runtime_evidence.rs +++ b/crates/mvm-runtime/src/runtime_evidence.rs @@ -32,6 +32,8 @@ pub const GUEST_RPC_PROTOCOL_VERSION: u32 = mvm_agentd::vsock::PROTOCOL_VERSION; #[derive(Debug)] pub enum VerifiedLaunchRuntime { Release(VerifiedRuntimeManifest), + #[cfg(feature = "pr-test-guest-runtime")] + PrTest(VerifiedRuntimeManifest), Source(CheckedSourceRuntime), LegacyBundle(crate::legacy_bundle_runtime::VerifiedLegacyBundleRuntime), LegacyPortable(crate::legacy_portable_runtime::VerifiedLegacyPortableRuntime), @@ -45,6 +47,8 @@ impl VerifiedLaunchRuntime { pub fn declaration(&self, arch: GuestArch) -> Result<&RuntimeAssembly> { match self { Self::Release(runtime) => Ok(runtime.declaration(arch)?), + #[cfg(feature = "pr-test-guest-runtime")] + Self::PrTest(runtime) => Ok(runtime.declaration(arch)?), Self::Source(runtime) => Ok(runtime.declaration(arch)?), Self::LegacyBundle(_) | Self::LegacyPortable(_) => { bail!("a legacy runtime has no CLI runtime assembly") @@ -59,6 +63,8 @@ impl VerifiedLaunchRuntime { ) -> Result { match self { Self::Release(runtime) => Ok(runtime.snapshot_artifacts_in(selection, parent)?), + #[cfg(feature = "pr-test-guest-runtime")] + Self::PrTest(runtime) => Ok(runtime.snapshot_artifacts_in(selection, parent)?), Self::Source(runtime) => Ok(runtime.snapshot_artifacts_in(selection, parent)?), Self::LegacyBundle(_) | Self::LegacyPortable(_) => { bail!("a legacy runtime has no CLI runtime assembly") @@ -118,6 +124,26 @@ pub fn verify_runtime_for_claim( if checked.is_release() != parent_assembly.release_proof().is_some() { bail!("warm child cannot change its parent's runtime provenance tier"); } + #[cfg(feature = "pr-test-guest-runtime")] + if matches!(checked, VerifiedLaunchRuntime::PrTest(_)) + != matches!( + parent_assembly.provenance, + RuntimeBindingProvenance::PrTest { .. } + ) + { + bail!("warm child cannot change PR test runtime provenance"); + } + #[cfg(feature = "pr-test-guest-runtime")] + if let (Some(requested), RuntimeBindingProvenance::PrTest { proof: parent }) = ( + config.pr_test_runtime_proof.as_ref(), + &parent_assembly.provenance, + ) { + anyhow::ensure!( + requested.pull_request == parent.pull_request + && requested.input.archive_sha256 == parent.input.archive_sha256, + "warm child requested a different PR test runtime" + ); + } if let ( VerifiedLaunchRuntime::Source(checked), RuntimeBindingProvenance::Source { archive_sha256, .. }, @@ -170,6 +196,14 @@ pub fn verify_runtime_binding(binding: &RuntimeLaunchBinding) -> Result<()> { RuntimeBindingProvenance::Release { proof } => VerifiedLaunchRuntime::Release( verify_runtime_release_proof(proof, binding.version(), GuestArch::host())?, ), + #[cfg(feature = "pr-test-guest-runtime")] + RuntimeBindingProvenance::PrTest { proof } => VerifiedLaunchRuntime::PrTest( + mvm_build::guest_runtime::evidence::verify_pr_test_runtime_proof( + proof, + binding.version(), + GuestArch::host(), + )?, + ), RuntimeBindingProvenance::Source { version, arch, @@ -474,7 +508,13 @@ fn validate_generation_paths(binding: &CliRuntimeLaunchBinding) -> Result Some(&proof.input), + RuntimeBindingProvenance::Release { .. } | RuntimeBindingProvenance::Source { .. } => None, + }); + if let Some(proof) = proof { for (path, name) in [ (&proof.archive, "archive"), (&proof.checksum, "checksum"), @@ -511,6 +551,11 @@ pub fn verify_release_runtime_for_launch( minimum_protocol: u32, ) -> Result { check_runtime_provenance(config)?; + #[cfg(feature = "pr-test-guest-runtime")] + anyhow::ensure!( + config.pr_test_runtime_proof.is_none(), + "PR test runtime cannot satisfy release runtime assurance" + ); if config.unsigned_runtime_development || config.source_runtime_archive_sha256.is_some() || config.legacy_bundle_runtime.is_some() @@ -569,12 +614,32 @@ fn verify_runtime_for_launch_at( version, GuestArch::host(), )?), - None => VerifiedLaunchRuntime::Source(checked_source( - version, - GuestArch::host(), - bound_home, - config.source_runtime_archive_sha256.as_deref(), - )?), + None => { + #[cfg(feature = "pr-test-guest-runtime")] + if let Some(proof) = &config.pr_test_runtime_proof { + VerifiedLaunchRuntime::PrTest( + mvm_build::guest_runtime::evidence::verify_pr_test_runtime_proof( + proof, + version, + GuestArch::host(), + )?, + ) + } else { + VerifiedLaunchRuntime::Source(checked_source( + version, + GuestArch::host(), + bound_home, + config.source_runtime_archive_sha256.as_deref(), + )?) + } + #[cfg(not(feature = "pr-test-guest-runtime"))] + VerifiedLaunchRuntime::Source(checked_source( + version, + GuestArch::host(), + bound_home, + config.source_runtime_archive_sha256.as_deref(), + )?) + } }; let declaration = verified.declaration(GuestArch::host())?; if declaration.protocol_version < minimum_protocol @@ -593,6 +658,8 @@ fn check_runtime_provenance(config: &VmStartConfig) -> Result<()> { config.runtime_release_proof.is_some(), config.legacy_bundle_runtime.is_some(), config.legacy_portable_runtime.is_some(), + #[cfg(feature = "pr-test-guest-runtime")] + config.pr_test_runtime_proof.is_some(), ] .into_iter() .filter(|present| *present) @@ -609,6 +676,16 @@ fn check_runtime_provenance(config: &VmStartConfig) -> Result<()> { fn has_external_runtime(config: &VmStartConfig) -> bool { config.runtime_release_proof.is_some() + || { + #[cfg(feature = "pr-test-guest-runtime")] + { + config.pr_test_runtime_proof.is_some() + } + #[cfg(not(feature = "pr-test-guest-runtime"))] + { + false + } + } || config.source_runtime_archive_sha256.is_some() || config.runtime_overlay_path.is_some() || config.runtime_overlay_verity_path.is_some() @@ -728,9 +805,24 @@ impl RuntimeLifecycleLease { )?, ); } - bound.unsigned_runtime_development = !verified.is_release(); + #[cfg(feature = "pr-test-guest-runtime")] + if let VerifiedLaunchRuntime::PrTest(pr_test) = &verified { + let proof = config + .pr_test_runtime_proof + .as_ref() + .context("PR test proof missing")?; + bound.pr_test_runtime_proof = + Some(mvm_core::protocol::runtime_release::PrTestRuntimeProof { + input: pr_test + .retain_release_proof_in(&proof.input, artifacts.directory_path())?, + pull_request: proof.pull_request, + }); + } + bound.unsigned_runtime_development = matches!(verified, VerifiedLaunchRuntime::Source(_)); bound.source_runtime_archive_sha256 = match &verified { VerifiedLaunchRuntime::Source(source) => Some(source.archive_sha256().to_owned()), + #[cfg(feature = "pr-test-guest-runtime")] + VerifiedLaunchRuntime::PrTest(_) => None, VerifiedLaunchRuntime::Release(_) | VerifiedLaunchRuntime::LegacyBundle(_) | VerifiedLaunchRuntime::LegacyPortable(_) => None, @@ -740,9 +832,18 @@ impl RuntimeLifecycleLease { Some(artifacts.overlay_verity_path().display().to_string()); bound.initrd_path = Some(artifacts.initramfs_path().display().to_string()); let binding = artifacts.directory_path().join("launch.json"); + let provenance = match &verified { + VerifiedLaunchRuntime::Release(_) => "release", + #[cfg(feature = "pr-test-guest-runtime")] + VerifiedLaunchRuntime::PrTest(_) => "pr_test", + VerifiedLaunchRuntime::Source(_) => "source", + VerifiedLaunchRuntime::LegacyBundle(_) | VerifiedLaunchRuntime::LegacyPortable(_) => { + unreachable!("legacy runtimes returned before CLI assembly") + } + }; let record = serde_json::json!({ "vm_name": config.name, - "provenance": if verified.is_release() { "release" } else { "source" }, + "provenance": provenance, "archive_sha256": artifacts.archive_sha256(), "overlay_roothash": artifacts.declaration().overlay_roothash, "overlay": bound.runtime_overlay_path, diff --git a/crates/mvm-runtime/src/universal_initramfs.rs b/crates/mvm-runtime/src/universal_initramfs.rs index a5e538f414..88ecc5fb6b 100644 --- a/crates/mvm-runtime/src/universal_initramfs.rs +++ b/crates/mvm-runtime/src/universal_initramfs.rs @@ -114,6 +114,14 @@ pub fn attach_universal_initramfs_if_cached( start_config: &mut mvm_core::vm_backend::VmStartConfig, hypervisor: &str, ) -> Result<()> { + #[cfg(feature = "pr-test-guest-runtime")] + if start_config.pr_test_runtime_proof.is_some() { + crate::runtime_evidence::verify_runtime_for_launch( + start_config, + crate::runtime_evidence::MIN_GUEST_RPC_PROTOCOL_VERSION, + )?; + return Ok(()); + } if start_config.legacy_portable_runtime.is_some() { crate::runtime_evidence::verify_runtime_for_launch( start_config, diff --git a/crates/mvm-runtime/src/workload_runner/standby_boot.rs b/crates/mvm-runtime/src/workload_runner/standby_boot.rs index 638d75d2f4..d64a51d195 100644 --- a/crates/mvm-runtime/src/workload_runner/standby_boot.rs +++ b/crates/mvm-runtime/src/workload_runner/standby_boot.rs @@ -179,12 +179,20 @@ pub fn factory_parent_config( runtime_overlay_roothash, runtime_overlay_version, runtime_release_proof, + #[cfg(feature = "pr-test-guest-runtime")] + pr_test_runtime_proof, unsigned_runtime_development, source_runtime_archive_sha256, mem_initial_mib, kernel_read_sha256, } = launch; + #[cfg(feature = "pr-test-guest-runtime")] + if pr_test_runtime_proof.is_some() { + return Err(StandbyError::SpawnFailed( + "PR test runtime supports cold launches only".into(), + )); + } if canonical_kernel.is_some() || kernel_read_sha256.is_some() { return Err(StandbyError::SpawnFailed( "kernel descriptor custody is cold-only; standby reuse is unsupported".into(), @@ -225,6 +233,8 @@ pub fn factory_parent_config( runtime_overlay_roothash: runtime_overlay_roothash.clone(), runtime_overlay_version: runtime_overlay_version.clone(), runtime_release_proof: runtime_release_proof.clone(), + #[cfg(feature = "pr-test-guest-runtime")] + pr_test_runtime_proof: None, unsigned_runtime_development: *unsigned_runtime_development, source_runtime_archive_sha256: source_runtime_archive_sha256.clone(), mem_initial_mib: *mem_initial_mib, diff --git a/public/src/content/docs/guides/pack-authoring.md b/public/src/content/docs/guides/pack-authoring.md index 61113dbd6a..e03d0f33db 100644 --- a/public/src/content/docs/guides/pack-authoring.md +++ b/public/src/content/docs/guides/pack-authoring.md @@ -10,8 +10,10 @@ and `mvmctl` verifies its signature every time it is pulled and every time a policy that names it is loaded. The registry source is the [`mvm-packs`](https://github.com/tinylabscom/mvm-packs) repository. -This guide uses two published packs as its worked examples: `runtime/node`, a -group, and `agent/codex`, a profile that includes it. For the policy language +The `runtime/node@1.0.0` group and `agent/codex@1.0.0` profile below are +**historical policy-only examples**, not portable application bytes or +independently runnable packs. The newer `agent/claude@2.0.1` is a complete +boot bundle, not an application-only component. For the policy language itself, see [Policy and profiles](/guides/policy-and-profiles/). ## What a pack contains @@ -118,6 +120,71 @@ boot-source precedence; the pack still contributes its policy. The separate `machine run --entrypoint` boot path refuses an image-bearing pack; use the ordinary machine run path. +## Portable application candidate (not yet released) + +The new component model keeps the signed application archive separate from +the selected Linux base and the separately signed CLI guest runtime. A +versioned application-only descriptor binds the payload digest, architecture, +ABI/libc requirements, policy and named commands; it contains no kernel or +bootable rootfs. Runtime/tool components bind their own archive digests and +policy. Existing published versions keep their original meanings. + +The candidate `mvm.pack-spec/v2` developer recipe currently describes one +**local** application and ordered runtime requests: + +```toml +schema = "mvm.pack-spec/v2" +target = "x86_64-linux" +runtimes = ["runtime/python"] +entrypoint = ["/usr/bin/python3", "/srv/app/main.py"] + +[identity] +name = "agent/example" +version = "1.0.0" + +[source] +kind = "local" +path = "." + +[[copy]] +source = "main.py" +destination = "/srv/app/main.py" + +[resources] +cpu_cores = 1 +memory_mb = 256 +``` + +The proposed development invocation is one command with either the default +base or a selected local kernel-and-composition source: + +```sh +mvmctl run --compose ./pack.toml --compose-base signed-default +mvmctl run --compose ./pack.toml --compose-base local \ + --compose-base-manifest /abs/local-image-set.json \ + --compose-base-kernel /abs/vmlinux \ + --compose-base-tar /abs/rootfs.tar.gz +``` + +The local manifest must describe the selected architecture's compatible +kernel and tar with exact digest and size. A base made locally—even from a +sealed Nix output—is development-only. The unsealed `default-tenant.dev` +image has no composition tar; an ext4-only local manifest is insufficient. +Neither command is yet a demonstrated `mvmctl` guest-output/cache-hit +workflow: the latest signed image set lacks a default composition tar and +there is no matching released full CLI runtime. Unknown/mismatched inputs +must refuse instead of falling back to another base. + +Local assembly measures ext4/verity before boot and signs a **local** +bundle/plan; it does not create publisher authority. Production export +requires separately trusted signed base and runtime evidence and +independent protected verification/signing of one self-contained +`.mvmpkg`. Neither `--prod` on a local composition nor +`mvmctl pack publish ./my-pack` is implemented as that protected path. +Only a proved CLI cold/repeat guest value plus admission/cache-hit check +can establish the candidate workflow. No guest NIC or network fallback +is part of this model; permitted egress remains FlowMux/vsock. + ## A group pack A group is a reusable fragment: hosts, secret bindings, shares, environment diff --git a/public/src/content/docs/reference/cli-commands.md b/public/src/content/docs/reference/cli-commands.md index de4664098b..6e2c59641b 100644 --- a/public/src/content/docs/reference/cli-commands.md +++ b/public/src/content/docs/reference/cli-commands.md @@ -985,7 +985,8 @@ never served from a standby and always boots cold. | `mvmctl run -- ...` | Boot an OCI image; the same as `--image ` | | `mvmctl run ./app.mvmpkg -- ...` | Verify and boot a publisher-trusted bundle or a same-host local build; local builds are reported as not release-verified and refused under `--prod` | | `mvmctl run -- ...` | Acquire and run an `agent/*` application, or run another installed application; `-a`, `--app` and `--application` acquire any application reference with no image fallback | -| `mvmctl run --compose ./pack.toml` | Resolve signed runtime inputs, assemble a verity-sealed default base and run the recipe's entrypoint from a host-signed **local build**. Rechecks publisher and revocation evidence on cache hits; refuses until a compatible signed Linux image set supplies a composition tar. Never publishes a pack or claims an official signature. | +| `mvmctl run --compose ./pack.toml` | Resolve signed runtime inputs, assemble a verity-sealed signed-default base and run the recipe's entrypoint from a host-signed **local build**. Rechecks publisher and revocation evidence on cache hits; refuses until a compatible signed Linux image set supplies a composition tar. Never publishes a pack or claims an official signature. | +| `mvmctl run --compose ./pack.toml --compose-base local --compose-base-manifest ./base/image-set.json --compose-base-kernel ./base/vmlinux --compose-base-tar ./base/rootfs.tar.gz` | Select an explicitly measured local kernel and composition tar under a schema-3 `local-dev` manifest. Development-only and host-signed; refuses `--prod`, missing tar, changed input bytes and incompatible architecture/boot contract instead of using the signed default. A successful Linux CLI guest-output/cache-hit run is not yet established. | | `mvmctl run --manifest -- ...` | Boot a registered manifest/template instead of the default | | `mvmctl run --manifest -- ...` | Verify, install, and boot a signed `.mvmpkg` bundle, as `machine run --manifest ` does | | `mvmctl run -- npm test` | Infer the runtime when no source is given (see **Runtime detection** below) | diff --git a/specs/contracts/composable-pack-build.md b/specs/contracts/composable-pack-build.md index 5f64fd0ea1..43aedbfb9f 100644 --- a/specs/contracts/composable-pack-build.md +++ b/specs/contracts/composable-pack-build.md @@ -1,356 +1,167 @@ -# Architecture — composable pack builds - -Decision: compose runtime layers at package-build time into one self-contained -application `.mvmpkg` per architecture. A closed runtime-layer descriptor -parser, publisher-authenticated metadata dispatch and exact-byte private -archive snapshot exist in source. Strict isolated tar staging and bounded, -dependency-first resolution of authenticated runtime metadata also exist, -as does bounded composition of authenticated runtime tar inputs into a private -filesystem tree. A producer-side signed base composition tar and -consumer-side authenticated fetch/base-plus-runtime composition are implemented -in source for future Linux sets. Application input, policy, archive installation, -authoring and final non-Nix `.mvmpkg` production are not integrated; no base -tar has been published or boot-witnessed. This decision does not authorize -publication or change existing signed pack versions. - -## Problem and goals - -An author describes one sandbox once. A development build is the default; an -explicit production build freezes and checks every input before it can produce -a signed, self-contained `.mvmpkg`. A `runtime/*` pack contributes reusable -software and policy, not another VM or another entrypoint. An application pack -contributes the single entrypoint. For example, composing `runtime/python` with -a Python application yields one VM with both programs and one application -entrypoint. - -`mvm-images` owns the generic, signed Linux base. It does not grow per-runtime -roles. `mvm-packs` owns immutable versioned pack descriptors, signed layer -inputs and their policy. `mvm` owns authoring, resolution, composition, the -final workload archive and consumer admission. No package build publishes a -new Linux base. - -The priorities are simple authoring, fast cold and repeat package builds, -small generic Linux bases, and no regression in offline verification or the -guest's isolation. "Small" describes unnecessary base and builder overhead; -a required Python or agent binary still occupies space in the final archive. - -## Approaches considered - -| Composition | Benefit | Cost | -| --- | --- | --- | -| **At package build (chosen)** | One existing-style sealed rootfs and one offline-bootable archive; runtime input resolution and assembly can be cached separately | Runtime bytes recur in distinct final application archives | -| At VM boot | Potential sharing of independently sealed runtime disks | New boot ABI, multi-disk integrity and mount ordering; embedding the disks still costs bytes in a self-contained archive | -| At install from external references | Smaller individual application downloads if runtimes are cached | No single self-contained file; offline use and trust depend on external installed layers | - -Keep the builder implementation in Rust and use the current filesystem, -verity, bundle and admission code. Nix remains a compatible producer, not -the required resolver or default package-build path. The generic Linux base -continues to be produced and signed by `mvm-images` on its own cadence; -choosing Buildroot instead of Nix for that base is an independent experiment. - -## Representations and ownership - -1. The authored, versioned pack spec says what to include and what the workload - requests. A small, strict TOML front end and the language SDKs produce the - same neutral spec; TOML is not a second execution IR. Common fields are - safe for both modes. Development-only configuration is never inherited by - production. Production requires an explicit mode selection and refuses - incomplete inputs. Keep `[production]` as the one spelled-out table name; - avoid an equivalent `[prod]` alias. Existing `mvm.pack-spec/v1` and lock - bytes retain their current meaning, including their current sealed build - behavior; the new default requires a new versioned contract. -2. The existing Workload IR says what runs. It has one final command entrypoint - and the resources and capabilities the workload requests. A runtime layer - cannot introduce an entrypoint or a host grant. -3. A resolved build plan says which exact bytes to assemble. It binds the - target architecture, accepted signed base-image-set root, an ordered list of - version-and-manifest-digest-pinned runtime layers, the application input, - every external source digest, and the compiler/producer versions. A - versioned lock records this resolved plan; an unpinned input cannot enter - production. The signed final artifact records the resolved inputs and - hashes of its output bytes. A changed authoring file, selected layer, - compiler or producer invalidates the relevant lock/cache key. - -The neutral `mvm.pack-spec/v2` contract and Rust SDK TOML front end now parse -this narrow first slice without changing v1: one local application, an ordered -list of `runtime/*` requests, `/srv/app` copies, literal absolute argv and -resource requests. Development is the default; `[production]` is an explicit -opt-in, not a grant or a completed frozen lock. Unknown networking, host grants -and signing fields are rejected. For example: - -```toml -schema = "mvm.pack-spec/v2" -target = "aarch64-linux" -runtimes = ["runtime/python"] -entrypoint = ["/usr/bin/python3", "/srv/app/main.py"] - -[identity] -name = "agent/example" -version = "1.0.0" - -[source] -kind = "local" -path = "." - -[[copy]] -source = "main.py" -destination = "/srv/app/main.py" - -[resources] -cpu_cores = 1 -memory_mb = 256 -``` - -Adding `[production]` at the end expresses intent but does not currently -make `mvmctl pack build` consume v2; that CLI still requires v1 and Nix. -Authenticated runtime requests can now be resolved in exact dependency order -and checked against candidate archives. The shared compiler snapshots only -declared application copies (or the full source when `.` is copied), then -rehashes those inputs without Nix; unrelated files do not invalidate the v2 -snapshot. The Rust SDK can package that held tree as a deterministic gzip tar -and reverify both the staged files and archive bytes. The private composer -now requires each requested runtime's signed archive and policy-file snapshots -together; a raw verified tar without those policy bytes cannot create a -production-authored lock. It accepts one digest-bound application tar only -under `/srv/app` and emits an ext4+verity candidate. An explicitly -production-authored candidate can produce -a versioned input record binding the spec, supplied source/revision, selected -profile, signed base root, runtime pins and application archive. This is not -yet a CLI-frozen build: source and producer-revision custody, health boot and -signed archive remain required. - -Separate the producer from the builder-VM backend. A producer may use Nix, -verified OCI content or pinned native payloads to make candidate filesystem -content, but it cannot sign, grant policy or publish. Reuse the existing -materialization, verity, exact-byte health boot, verifier and bundle signer -after all candidates pass the same output contract. Signing keys and -publication credentials never enter a builder or an authored spec. - -## Layer, archive and trust boundaries - -Prefer a new `runtime_layer` registry kind over widening the bootable -`.mvmpkg` parser to accept kernel-less inputs. A versioned registry-signed -manifest binds a deterministic, bounded external layer tar by canonical -HTTPS or digest-pinned OCI source, SHA-256 and size. Only the small policy -documents live under the registry's `files/` tree; a multi-GiB tar cannot -live in Git. The signed descriptor also binds the target architecture, -immutable pack identity and declared runtime dependencies. The tar carries -filesystem entries only: no kernel, initramfs, entrypoint, network setup, -whiteouts or executable install script. -Reuse the existing hardened OCI-layer unpacker as far as its safe subset fits, -and reject paths, file types or metadata it cannot faithfully represent. -The registry manifest signature covers the tar digest, so a second layer -signer is unnecessary. This new kind requires a producer and consumer -contract; existing policy and application manifest bytes keep their meaning. - -The first descriptor parser accepts outer `schema_version = 3`, kind -`runtime_layer`, an exact `runtime/*@version` reference and description; -`runtime_layer` carries schema 1, guest architecture and an ordered list of -exact manifest-digest-pinned `runtime/*` dependencies and an `archive` -object with `source`, `archive_sha256` and `archive_size`. Its `files` -inventory contains at least one of `pack/group.toml` or -`pack/profile.toml`, each with a digest and positive size. It rejects -unknown fields, extra file paths, duplicate files, self-dependencies, -non-runtime dependencies, and more than 16 direct dependencies. The -external tar is bounded to 2 GiB; producer and consumer must also bound -its unpacked contents. Parsing alone does not authenticate a publisher; -the distinct registry dispatch does, and the snapshot checks the -descriptor-pinned external size and hash. A separate staging step refuses -unsupported tar entry types, duplicate paths, whiteouts, setid bits, xattrs -and over-budget contents, and protects host-owned guest paths such as -`/etc/mvm`, `/mvm`, `/dev` and `/etc/resolv.conf`. It does not merge a layer -with the Linux base or admit it as a runnable image. The `mvm-packs` -publisher owner has aligned its unpublished producer branch to this shape. -One architecture per version is sufficient for the first Python spike; -multi-architecture fan-out requires a separately versioned schema decision. - -The package builder verifies each selected layer's registry publisher, -manifest signature, file digest, revocation and exact lock pin *before* -reading its files as build input. Signed `pack/group.toml` and -`pack/profile.toml` bytes must be snapshotted and verified as well as the -external tar; neither a catalog hash nor an unverified local policy file can -stand in for the publisher's signed manifest. Policy composition must keep -the signed layer origin and deny/ceiling semantics until host admission. -The protected final signer independently -checks the frozen input evidence and the output selected for health boot, -then signs a complete application `.mvmpkg`. The recipient authenticates -that final archive, its registry pin and current admission policy; it does -not need the original layer registry or source tree at boot. The signed -final manifest records the input layer manifests and output digests so a -later runtime-layer revocation can refuse derived application bundles. -An offline production consumer must preserve the existing freshness rules -for revocation evidence. The final publisher's signature is not proof that -an unverified upstream layer was safe or reproducible. - -Keep the registry's keyless publisher identity and the bundle's independently -enrolled Ed25519 key as two separate trust roots. A versioned in-file keyless -signature, signed build-provenance statement and pack/policy trace identities -are proposed by `mvm-packs` ADR 004, not yet shipped by this candidate path. -Do not label a local resolved lock or host-signed archive as those statements. -The final format needs to bind the producer revision, exact runtime manifest -and policy hashes, base root, generated artifact hashes and health-boot subject -under the same independently verified provenance and admission rules; if the -consumer cannot verify them, it must refuse production. - -## Composition and security rules - -- Resolve runtime references to immutable, authenticated versions before - composition; reject duplicates, cycles, architecture mismatches, revoked - dependencies and a changed manifest digest. The resolution order is fixed - and recorded in the lock. -- Materialize the signed Linux base, then each runtime layer in locked order, - then the application. For the first format, reject overlapping file paths - rather than allow a later layer to silently replace an earlier one. Refuse - symlink escapes, special files, missing dynamic loaders, unbounded archives - and unverified bytes. Keep build tools out of the final rootfs unless the - workload explicitly needs them. -- Exactly one application owns the entrypoint. Runtime layers may contribute - binaries, libraries and declared logical requirements. Compose policy by - field using the existing policy rules: requested allows combine, denials - prevail, resource ceilings take the minimum, and secret destinations may - only narrow. Check the signed application ceiling and intersect the request - with host-authorized grants at admission; a pack's request never grants itself - access. The host remains the authority for actual grants and secret values. -- Neither development nor production has a guest NIC, SSH, port forwarding, - guest NAT or a network fallback. Any permitted egress uses the authenticated - host-mediated FlowMux path. Development may request a user-authorized - writable workspace; production cannot inherit that request or install - dependencies on first boot. -- The final `.mvmpkg` contains everything needed for offline verification and - boot: the selected kernel and initramfs, the composed rootfs and verity - material, its signed environment and provenance, and all required runtime - bytes. A registry descriptor separately pins that exact archive. Runtime - layers can be cached and reused while building, but the recipient need not - have those layers in its cache to run the final archive. - -## Missing pieces - -The v2 authored contract accepts only a local source today; its TOML parser -does not lower to Workload IR or write/read a frozen lock in the CLI. Its -runtime requests can be bound to authenticated, ordered descriptors and -checked again before -base-plus-runtime candidate composition. The v1 pack lowering still -selects Nix packages. The frozen v1 lock pins a compiler revision, not a -graph of independently authenticated layers. `mvmctl pack build` currently -boots a Nix-built slot and its exported environment has no pack policy, secret -slots, volumes or capabilities. Authenticated runtime-layer metadata and -external archive and declared policy bytes can be verified in source, and -authenticated dependency pins can be ordered with cycle, architecture, -digest, duplicate-name and revocation checks. A build-only acquisition API now -accepts an exact caller-supplied pin, publisher-authenticates the descriptor, -requires fresh revocations, and holds the signed archive and policy bytes in -private snapshots. Its first remote transport accepts only mvm-packs HTTPS -GitHub release assets; the signed digest, not the release URL, makes their -contents immutable to the builder. OCI and other HTTPS origins fail closed until a -separately reviewed transport exists. For the first Python slice both local -and remote build-input acquisition refuse anything but a signed deny-all group -with `required = true`, no profile, network routes, secrets, shares, tools, environment overrides -or resource requests. This is a first-slice restriction, not the general -policy-intersection algorithm, and host admission must independently enforce -it before a package can run. It neither freezes a v2 CLI lock nor -installs an image: `pack registry pull` and the installed-pack reader still -refuse a runtime layer. A signed base tar can be fetched separately -from the boot artifacts and merged with runtime archives in order without -replacing base-owned files, retaining tar ownership under one aggregate size -and entry budget. A digest-bound application archive can be added under -`/srv/app` and emitted as an ext4+verity candidate. Its resolved input record -is not a cache install or a runnable bundle: source custody, symlink closure, -policy, release proof and health boot still require final checks. -The workload bundle needs a versioned, signed composition member that -binds runtime manifest pins for transitive revocation checks; old bundle -schemas remain unchanged. Coordinate its schema floor with the separate -universal-pack work in `mvm-packs` ADR 004 (builder version, policy and -keyless provenance members), rather than independently claiming the next -number. Carry one typed composition member, not reuse the v7 `other` role -as an implicit grant. Its Ed25519-signed manifest binds the authored -spec/source/producer lock, selected signed Linux root, ordered runtime manifest pins, policy -digests, output artifact hashes and health-tested entrypoint. Embed each -runtime's original keyless-signed descriptor, signature bundle and declared -policy document as small digest-bound bundle artifacts; a clean offline -recipient must verify both publisher and bundle trust roots and check -current revocations for every pinned layer before admission. The final -package carries the composed rootfs and CLI runtime itself, not an external -layer fetch dependency. Missing evidence, ambiguous policy, changed -bytes or an old-schema bundle claiming composition must refuse, never fall back -to an unsigned local cache or guest install script. This is a source -contract, not a completed exporter or consumer. -The CLI must not interpret any new source, mode or layer field as optional -and silently fall back to Nix. A local production-mode build is not an -official published release merely because it is sealed and host-signed. - -The format owner is `mvm`, coordinated with the `mvm-packs` publisher owner. -The registry producer and consumer must learn the new layer kind together -before any layer is advertised as supported. Do not mutate existing -application or policy-only manifests to imitate it. - -## Spike and decision rule - -The first **unsigned** x86_64 Python source experiment is -[`scripts/build-python-runtime-layer.py`](../../scripts/build-python-runtime-layer.py). -It accepts only the SHA-256-pinned `cpython-3.13.18+20261026` -`x86_64-unknown-linux-musl-lto+static-full.tar.zst` from the Astral -python-build-standalone release (`e0bb0840e127aa95dbb1212202a29cde715711ae6826132e548f5eb04a958533`). -It repacks the static interpreter and runtime-only standard library as a -deterministic plain `layer.tar`; it does not run untrusted install scripts, -sign, publish, or grant policy. The interpreter has no ELF `PT_INTERP` loader. -Independent macOS and Linux runs produced SHA-256 -`9f5dfaf983a54a62212184f08f5dfc36d1474052d71a799cdfd3f1a53dc970d4` -and 63,836,160 bytes; the signing smoke pins these output bytes. -The upstream GitHub asset digest establishes *byte identity*, not an -independent publisher-signature or reproduction claim. A production publisher -must accept that upstream trust choice explicitly, sign a new -`runtime/python@1.1.0` descriptor binding the actual output SHA-256 and size, -and pass the consumer and exact-archive boot gates. The historical revoked -policy-only `runtime/python@1.0.0` is not reusable. - -Keep the Nix producer working unchanged. Resolve one authenticated Python -runtime input into a runtime layer, add a small Python application as a distinct -input and produce one candidate with one entrypoint without invoking Nix in the -package-build path. Compare against the current Nix path using the same -application and target: cold and warm elapsed build time, fetched bytes, cache -reuse, generated archive size, and guest boot. Report the input acquisition -time separately from filesystem assembly and signing time. - -Choose this producer path for the next package class only if it boots the -exact self-contained archive, preserves all trust and isolation checks, and -improves measured end-to-end build time or complexity without an unacceptable -size regression. Otherwise retain Nix for that class and identify the costly -stage before adding a different producer. The accepted build-time composition -decision does not depend on Buildroot winning the separate base-image test. - -Before calling the path production-ready, reproduce the unsigned payload -bytes in independent builds, verify the pinned base and every runtime input, -boot the composed image and execute Python from the final archive on both -supported architectures, then demonstrate refusal on tampered layers, missing -loaders, stale locks, conflicting paths, widened policy and a changed tested -artifact. Signing timestamps and externally supplied signatures are not -payload reproducibility evidence. Do not infer publication or a supported -release from source-only tests. - -## Open questions - -- Future Linux sets add a deterministic, signed `rootfs.tar.gz` artifact - alongside each production default/rootless ext4 member. The producer makes - both from the same fakeroot tree; the existing 17 members and historical ext4 - bytes are unchanged. The consumer selects the tar only through the verified - set root and refuses missing bytes or changed digests. Reproduction of - published bytes and signed-set admission still need native CI evidence; - the ext4 tree-diff reader cannot substitute for this tar without losing - ownership and xattr fidelity. -- Linux Docker builds produced both default and both rootless base tars - (native aarch64, emulated x86_64) from the local Nix definitions. - The default tars materialized in Rust on macOS to ext4+verity candidates - with zero file, path or mode differences from their respective Nix ext4s. - The rootless tar contains case-distinct paths that APFS cannot stage - faithfully: Rust refuses it instead of signing a changed image. Route - rootless (and any similarly colliding runtime) through a case-sensitive - Linux execution boundary. The emulated x86_64 build needed Nix's inner - syscall filter disabled for local validation; neither Docker build is - release reproduction or boot evidence. -- Requested egress composition and host grant semantics have conflicting - descriptions in the pack ADR, PRD and merge guide. The effective policy - needs one tested, signed, fail-closed rule before production composition. -- The spike determines whether a pinned, signature-verified OCI Python - userspace is both faster and smaller enough than the current Nix closure. - Native Claude payload redistribution is a separate permission and - loader-compatibility gate, not a prerequisite for the Python spike. +# Contract — portable application composition + +Status: release-candidate design, **not** an accepted or published feature. +See `mvm-packs` ADRs 004–006. This contract replaces the fixed-default-base, +single-application assumptions of the first source-only candidate without +changing any published pack or `mvm.pack-spec/v1` semantics. + +## What is independently reusable + +A portable **application pack** carries signed, digest-bound application bytes, +declared paths, immutable identity, requirements, policy and optional +entrypoint. It contains neither a kernel nor an independently bootable rootfs. +A **runtime/tool pack** carries separately signed software and policy; a +runtime layer has one publisher-authenticated registry descriptor binding its +external archive digest and size, **not** a second Ed25519 archive signer. +Neither kind can boot alone. Existing schema-2 application descriptors still +point at complete, independently signed `.mvmpkg` boot bundles, including the +published Claude versions; legacy policy-only versions remain immutable and +non-runnable. Keep both the existing v1 release reader and v1 authoring while +the new format reaches parity. Unknown versions fail closed. + +The Linux **base** supplies the kernel and a generic, verifiable filesystem +composition source; `mvm` supplies the separately signed complete guest +runtime/overlay/initramfs. An app must work unchanged with either our default +or a compatible user-built base. `mvm-images` publishes one optional default +base on its own cadence, never per-app images or mvm runtime. Its unpublished +schema-3 signed `rootfs.tar.gz` is a composition source, not a second rootfs +or runtime role. The last known signed set, v0.2.4, lacks that tar; do not +advance `images.lock` or publish a set without the released-consumer gates and +specific authorization. + +## Selection, compatibility and authority + +`mvmctl run agent/claude` stays a single command with a fast compatible +default. An explicitly ordered composition with `--base` is also a single +command. The selected base, not a fixed compiled-in default, is an input to +**one** resolver, assembler, cache and admission path. Do not retry with the +default when a selected base is incompatible or untrusted. + +A bounded, versioned selected-base record must bind its trust class, +architecture, boot protocol, kernel digest/size, composition filesystem +digest/size, base-image contract, filesystem ABI, minimum kernel features, +libc/loader compatibility, capability set, and independently signed guest +runtime evidence. Check the actual bytes and compatibility of all selected +roles. A development record may bind a user-built base with measured local +evidence; it must be marked local and cannot be relabelled, indexed or +admitted in production. Production requires independently trusted signed +base and runtime evidence, fresh revocations and a protected verifier; a +host-signed build is never official publisher authority. An unsigned or +incompatible production base is rejected with no fallback. Do not accept +unsupported special files, symlink escapes or an inconsistent base ext4/tar +pair merely because their declared digests are well formed. + +`mvm.pack-spec/v2` currently parses one local application, ordered `runtime/*` +requests, `/srv/app` copies, literal argv and resources. Its `[production]` +table expresses intent only: it neither signs nor authorizes a release. +The development-only CLI candidate accepts `mvmctl run --compose PACK.TOML` +with the signed default or an explicit local manifest/kernel/tar selection. +The local selection carries distinct schema-9 same-host proof, not canonical +publisher authority. The separate `base-compatibility/v1` descriptor verifier +binds exact base bytes and compares component requirements, but no signed +production descriptor is published or acquired by this CLI path. A new +versioned multi-component request must connect the existing v3 signed graph, +verified schema-3 application/schema-4 runtime and tool inputs, and entrypoint +choice without changing the old v2 recipe's meaning. An unpinned production +input, an unknown requirement, or a production request without separately +trusted base evidence fails closed. + +`mvm.composed-run/v1` is the proposed one-file v3 invocation envelope: it +requires a Linux target, the `mvm.pack-spec/v3` ordered graph, and an +`mvm.entrypoint-request/v1` command selection. Base selection and host grants +are separate launch inputs; this authored envelope grants neither. Unknown +fields, including implicit service startup, are refused. The contract parses +and validates the envelope, but `mvmctl run --compose` does not consume it yet. + +## Resolution, filesystem and cache + +Resolve an **explicit ordered graph** of publisher-authenticated, +exact-version-and-manifest-digest-pinned components. Resolve dependency pins +before assembly; refuse duplicates, cycles, missing trust, revoked inputs +and arch/ABI/libc/kernel/capability mismatches. Authenticate descriptor +signatures and snapshot every external archive and signed policy file against +the descriptor's digest/size before unpacking. Check fresh, rollback-resistant +revocation feeds on every run, including cache hits. + +Stage the selected base first, then locked runtime/tool components and +applications in their explicit order. Reject collisions, including a leaf +against a base directory, permission or ownership changes, whiteouts and +overlaps with host-owned guest paths. No component silently replaces a file, +mode, symlink or parent. No guest install script, boot-time package fetch or +implicit service start. Each application keeps its commands available by +name, but only the selected entrypoint executes. + +Build and measure one ext4/verity workload **before first boot** and cache it. +The filesystem cache key is a versioned hash of the exact selected base +composition bytes, ordered component archive digests and declared filesystem +ABI, and assembler version. Do not key those bytes on the effective policy, +host grants or command if they are not written into the filesystem. If any +such data *is* embedded in the filesystem, it is explicitly hashed as an +assembly input. Reverify cached bytes and their verity binding before use; +the cache is not a trust decision. First-run assembly, repeat boot, transfer +and retained disk must be measured separately. Reuse the candidate's +host-signed local-bundle/schema-8 path, not another composer or cache. + +Entrypoint precedence is: **runtime override > user-defined workload/config +entrypoint > last application in explicit order declaring an entrypoint**. +“Last wins” applies only to command selection. The selected command is +separately admitted under the selected security profile and host-authorized +grants, even for production overrides. Record and audit the actual command +executed, not merely a declared default. Signed component policy requests +cannot grant themselves network, secrets or shares: denies win, ceilings +narrow, and effective requests intersect host grants. Resolve and test policy +in one authoritative path. + +## Launch and export + +Bind selected base identity and trust class, signed guest runtime, +ordered component manifest/payload/policy hashes, measured output, +effective policy, grants and selected *actual* command to the existing +signed bundle and signed execution plan. Host admission rechecks input +publisher trust, freshness and rollback of revocations, measurements, +effective policy and grants **immediately before launch**, even on a cache +hit. Missing or conflicting evidence refuses launch. Local composition is +always marked development-only. + +For production, builder-vm materializes candidates without release +credentials. A separate protected pipeline independently verifies the +frozen graph and selected boot closure, checks the tested output and command, +and signs one self-contained `.mvmpkg` carrying kernel, initramfs, composed +ext4/verity, signed runtime evidence and full input provenance. The recipient +verifies it independently and applies current revocation and grant rules. +Neither a local receipt nor a host signature can stand in for protected +publisher signing. PR-only synthetic signing is allowed for tests but does +not enroll a production trust root or authorize publication. The prospective +`mvmctl pack publish ./my-pack` is a separate protected-publishing slice, +not an alias for local export. + +There is **no guest NIC, TAP/TUN, NAT, SSH, CNI, SLIRP or fallback**. Permitted +networking uses only host-mediated, authenticated FlowMux/vsock. + +## Acceptance and currently missing evidence + +The staged v2 candidate authenticates runtime descriptors/policies and a +signed-base composition tar, rejects path collisions, and creates a +host-signed local development bundle for a default or explicitly selected +local base. A verified host-signed filesystem may be reused across command +choices when its ordered filesystem identity is unchanged; current trust, +revocations and grants must still be readmitted. Typed v3 component readers, +an exact-pin acquisition helper, ordered staging and measured filesystem-only +lock, a separately signed base-descriptor verifier, and an authenticated +policy-snapshot adapter exist. The filesystem lock grants no command or host +authority; v3 is not wired into the CLI invocation, admission or production +export. +Direct-QEMU synthetic boots read `COMPOSED_GUEST_VALUE=print('ready')`; those +are *not* `mvmctl run --compose` admission, guest-output or cache-hit evidence. +The previously failing #5263 workspace test was fixed; passing it is not a +substitute for the missing CLI cold/repeat Linux witness. + +Acceptance requires Linux **CLI** one-command default and selected-custom-base +cold/repeat runs, a value actually read from the guest on both runs, a proven +filesystem cache hit, and rejection of tampered, revoked, stale, conflicting +and incompatible inputs. Preserve the direct-VMM checks as additional +evidence. Measure assembly and boot times, transferred and retained bytes; +run formatting, Clippy, invariant/footprint and registry gates. Synthetic +applications avoid Claude redistribution. No compatible published base tar +or released CLI/full-runtime closure yet exists; no CI result alone overrides +that publication prerequisite. diff --git a/xtask/src/check_two_surfaces.rs b/xtask/src/check_two_surfaces.rs index 5117185dbf..7cdb1547e1 100644 --- a/xtask/src/check_two_surfaces.rs +++ b/xtask/src/check_two_surfaces.rs @@ -26,7 +26,7 @@ const SURFACES: [&str; 2] = ["host", "user"]; /// forwards are inlined in the root Cargo.toml); what remains here is `default` /// plus platform/storage opt-ins that genuinely cannot always be on. Keep this /// list tiny — a new entry is a conscious "this cannot live in a surface". -const INTERNAL: [&str; 10] = [ +const INTERNAL: [&str; 12] = [ "default", // Platform-native caller custody; included by the user product surface. "native-caller-identity", @@ -47,6 +47,10 @@ const INTERNAL: [&str; 10] = [ // Dev/test-only: the hermetic in-memory mock VM backend. Never part of a // shipped binary; only this workspace's own test suite turns it on. "test-support", + // PR-only signed runtime fixtures; normal and release-channel builds refuse them. + "pr-test-guest-runtime", + // PR-only authenticated base fixture; its output remains host-local Dev. + "pr-test-synthetic-base", ]; /// A couple of release/build-only flags are allowed but explicitly *not* part of