measure: a launch, stated as a number, bound to what would sign for it

A boot is now an ordered log of what the hypervisor was handed — kernel,
command line, initrd where one is used, root image, machine shape — folded
into one extend-only SHA-384 register. The fold is the rule TDX applies to
an RTMR, and SHA-384 is the digest both TDX registers and the SEV-SNP launch
measurement use, so nothing is re-hashed at the boundary to hardware.

Only content enters a digest. A path is printed for the reader and never
hashed, so the same assets measure identically everywhere and a file digest
is an ordinary `shasum -a 384`. Digests are remembered against each image's
device, inode, length and mtime: 19 s of reading a 4 GB root image becomes
9 ms, warmed where a slow path is already being paid (after a download,
after a checkpoint save).

`hanzo-vm measure` states a launch without performing one. `run --stdio`
reports the launch log before it says `ready` — over the source images,
taken before anything is cloned — so a driver measures what it received
rather than what it asked for, and extends a second register with whatever
it deploys.

SHA-512(launch ‖ workload) is 64 bytes, the width of REPORT_DATA in an
SEV-SNP report and of a TDX TDREPORT's report data. ATTEST_REQ carries
exactly those bytes to the guest, which asks /dev/sev-guest or
/dev/tdx_guest and answers with what the platform said. The ask is
guest-side because that is the only side those devices exist on; a report
from the host would answer a different question. What is missing is
hardware: an SNP- or TDX-capable VMM on EPYC or Xeon. The ioctl numbers and
structure sizes are pinned by tests against the kernel headers, which is the
part a machine without the hardware can still check.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-09-05 22:54:49 -07:00
co-authored by Hanzo Dev
parent 886ebb04d9
commit 605b0dc7f3
29 changed files with 1474 additions and 88 deletions
+28
View File
@@ -1,5 +1,33 @@
# Changelog
## 2.0.1
Measured boot. A launch can now be stated as a number before it happens, and
that number is the one a confidential-computing platform would sign for.
- New crate `vm-measure`: an extend-only SHA-384 register over an ordered log
of what a launch loaded, the fold Intel TDX applies to an RTMR. A file
digest is a plain SHA-384, and paths are never hashed, so the same kernel
and root image measure identically on every machine that holds them.
Digests are remembered against each image's device, inode, length and
mtime — a warm measurement is milliseconds against 19 s of re-reading.
- `hanzo-vm measure` prints the document for a launch without performing it,
at the shape (`--cpus/--memory/--disk-size`) and image (`--from`) a boot
would use. `--recompute` ignores remembered digests.
- `run --stdio` sends a `measurement` notification carrying the launch log
before `ready`, and answers an `attest` method: 64 bytes in, the guest's
platform status out.
- Wire: `ATTEST_REQ`/`ATTEST_RESP` (0x60/0x61). The request payload is the 64
bytes themselves. The guest asks `/dev/sev-guest` or `/dev/tdx_guest` — a
report has to come from inside the guest the platform protects — and
answers `none` where there is neither. AMD EPYC with SEV-SNP or Intel Xeon
with TDX, under a VMM that starts such guests, is what would exercise it;
none of macOS/VZ, the arm64 KVM backend or cloud-hypervisor does yet.
- `vm::command_line` is the one place the kernel command line is decided, so
a measurement states the same string the hypervisor is handed.
- `boot::plan` separates resolving a launch from performing one; a
measurement is over the source images, taken before anything is cloned.
## 2.0.0
`cargo install hanzo-vm` now installs this CLI. The `hanzo-vm` name on
Generated
+82 -11
View File
@@ -172,6 +172,15 @@ dependencies = [
"cpufeatures 0.2.17",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "block2"
version = "0.6.2"
@@ -428,6 +437,16 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "defmt"
version = "0.3.100"
@@ -478,6 +497,16 @@ dependencies = [
"powerfmt",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "dispatch2"
version = "0.3.1"
@@ -638,6 +667,16 @@ dependencies = [
"winapi",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -694,7 +733,7 @@ dependencies = [
[[package]]
name = "hanzo-vm"
version = "2.0.0"
version = "2.0.1"
dependencies = [
"anyhow",
"base64",
@@ -710,6 +749,7 @@ dependencies = [
"tracing-subscriber",
"ureq",
"vm-core",
"vm-measure",
"vm-proto",
"vm-proxy",
"vm-store",
@@ -1552,6 +1592,17 @@ dependencies = [
"zmij",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
@@ -1690,7 +1741,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
@@ -1895,6 +1946,12 @@ dependencies = [
"tracing-log",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.24"
@@ -2040,7 +2097,7 @@ dependencies = [
[[package]]
name = "vm-ch"
version = "2.0.0"
version = "2.0.1"
dependencies = [
"crossbeam-channel",
"libc",
@@ -2055,7 +2112,7 @@ dependencies = [
[[package]]
name = "vm-core"
version = "2.0.0"
version = "2.0.1"
dependencies = [
"anyhow",
"crossbeam-channel",
@@ -2067,12 +2124,13 @@ dependencies = [
"vm-ch",
"vm-darwin",
"vm-linux",
"vm-measure",
"vm-proto",
]
[[package]]
name = "vm-darwin"
version = "2.0.0"
version = "2.0.1"
dependencies = [
"block2",
"crossbeam-channel",
@@ -2091,7 +2149,7 @@ checksum = "7e21282841a059bb62627ce8441c491f09603622cd5a21c43bfedc85a2952f23"
[[package]]
name = "vm-guest"
version = "2.0.0"
version = "2.0.1"
dependencies = [
"flate2",
"ignore",
@@ -2100,12 +2158,13 @@ dependencies = [
"serde_json",
"tar",
"ureq",
"vm-measure",
"vm-proto",
]
[[package]]
name = "vm-linux"
version = "2.0.0"
version = "2.0.1"
dependencies = [
"crossbeam-channel",
"kvm-bindings",
@@ -2117,6 +2176,18 @@ dependencies = [
"vmm-sys-util 0.12.1",
]
[[package]]
name = "vm-measure"
version = "2.0.1"
dependencies = [
"anyhow",
"libc",
"serde",
"serde_json",
"sha2",
"tempfile",
]
[[package]]
name = "vm-memory"
version = "0.15.0"
@@ -2142,7 +2213,7 @@ dependencies = [
[[package]]
name = "vm-proto"
version = "2.0.0"
version = "2.0.1"
dependencies = [
"serde",
"serde_json",
@@ -2150,7 +2221,7 @@ dependencies = [
[[package]]
name = "vm-proxy"
version = "2.0.0"
version = "2.0.1"
dependencies = [
"anyhow",
"boring",
@@ -2173,7 +2244,7 @@ dependencies = [
[[package]]
name = "vm-sdk"
version = "2.0.0"
version = "2.0.1"
dependencies = [
"anyhow",
"blake3",
@@ -2188,7 +2259,7 @@ dependencies = [
[[package]]
name = "vm-store"
version = "2.0.0"
version = "2.0.1"
dependencies = [
"anyhow",
"blake3",
+2
View File
@@ -11,6 +11,7 @@ members = [
"crates/vm",
"crates/vm-store",
"crates/vm-guest",
"crates/vm-measure",
]
# vm-darwin and vm-linux are platform-specific — only pulled in
# transitively via vm's cfg'd deps. Excluded from default builds
@@ -23,6 +24,7 @@ default-members = [
"crates/vm",
"crates/vm-store",
"crates/vm-guest",
"crates/vm-measure",
]
[profile.release]
+40
View File
@@ -143,6 +143,46 @@ hanzo-vm checkpoint list
hanzo-vm checkpoint delete myenv
```
### Measurement
`hanzo-vm measure` states what a boot would be — the kernel, the command line
the kernel is given, the root image and the machine's shape — as SHA-384
digests folded into one extend-only register:
```text
e_i = SHA-384( name_i ‖ 0x00 ‖ SHA-384(content_i) )
R_0 = 0…0 (48 zero bytes)
R_{i+1} = SHA-384( R_i ‖ e_i )
```
```sh
hanzo-vm measure # the base image
hanzo-vm measure --from k3s --cpus 4 # a checkpoint, at the shape it will boot
hanzo-vm measure --recompute # read the images again, ignoring cached digests
```
Only content is hashed; the path a file was read from is printed for the
reader and never enters a digest, so the same assets measure identically on
every machine that holds them. A file digest is an ordinary SHA-384 — `shasum
-a 384 ~/.hanzo/vm/Image` prints the `kernel` event's digest. Digests are
remembered against each image's device, inode, length and mtime, so a boot
measures in milliseconds instead of re-reading gigabytes; `--recompute` skips
the cache.
The fold is the rule Intel TDX applies to a runtime measurement register
(`RTMR := SHA-384(RTMR ‖ 48 bytes)`), and SHA-384 is the digest both TDX
registers and the AMD SEV-SNP launch measurement use.
`run --stdio` reports the launch register as a `measurement` notification
before it says `ready`, so a driver measures what it received rather than
what it asked for, and can extend a second register with whatever it then
deploys. `SHA-512(launch ‖ workload)` — the `bind` field — is 64 bytes, the
width of the caller field in both a SEV-SNP attestation report and a TDX
TDREPORT; the `attest` method sends those bytes into the guest, which asks
`/dev/sev-guest` or `/dev/tdx_guest` for a report over them. On hardware
without either device the answer is `"platform": "none"`, which is what all
three machines this was built on answer.
### Secrets
A secret never enters the guest. The guest sees a random placeholder in the
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "vm-ch"
version = "2.0.0"
version = "2.0.1"
edition = "2021"
description = "cloud-hypervisor backend for Hanzo VM"
license = "Apache-2.0"
+6 -5
View File
@@ -1,6 +1,6 @@
[package]
name = "hanzo-vm"
version = "2.0.0"
version = "2.0.1"
edition = "2021"
description = "Hanzo microVM CLI: boot a Linux guest, run a command, throw the disk away"
license = "Apache-2.0"
@@ -15,10 +15,11 @@ path = "src/main.rs"
tempfile = "3"
[dependencies]
vm-core = { version = "2.0.0", path = "../vm" }
vm-proto = { version = "2.0.0", path = "../vm-proto" }
vm-proxy = { version = "2.0.0", path = "../vm-proxy" }
vm-store = { version = "2.0.0", path = "../vm-store" }
vm-core = { version = "2.0.1", path = "../vm" }
vm-proto = { version = "2.0.1", path = "../vm-proto" }
vm-proxy = { version = "2.0.1", path = "../vm-proxy" }
vm-measure = { version = "2.0.1", path = "../vm-measure" }
vm-store = { version = "2.0.1", path = "../vm-store" }
blake3 = "1"
clap = { version = "4", features = ["derive", "env"] }
anyhow = "1"
+6
View File
@@ -76,6 +76,12 @@ fn download_os_image_version(data_dir: &str, version: &str) -> Result<()> {
let version_file = format!("{}/VERSION", data_dir);
fs::write(&version_file, format!("{}\n", version)).context("failed to write VERSION file")?;
// Measure the images while they are fresh on disk, so the first boot from
// them reads a digest rather than a gigabyte.
for image in ["Image", "rootfs.ext4"] {
crate::measure::warm(&format!("{}/{}", data_dir, image));
}
eprintln!("hanzo-vm: OS image ready ({})", version);
Ok(())
}
+58 -30
View File
@@ -44,10 +44,12 @@ pub(crate) fn trace_boot(label: &str) {
}
}
pub(crate) struct PreparedVm {
pub instance_dir: String,
/// What a launch IS: everything the hypervisor will be handed, decided before
/// anything is copied. Separated from [`PreparedVm`] because a launch can be
/// stated without being performed — `hanzo-vm measure` prints one, and the
/// measurement it prints has to be of the same inputs a boot would use.
pub(crate) struct Plan {
pub source_rootfs: String,
pub work_rootfs: String,
/// If restoring from a CAS checkpoint, the index path.
pub cas_index: Option<String>,
pub kernel_path: String,
@@ -56,6 +58,16 @@ pub(crate) struct PreparedVm {
pub memory: u64,
pub disk_size: u64,
pub proxy_config: Option<vm_proxy::config::ProxyConfig>,
pub verbose: bool,
pub forwards: Vec<PortMapping>,
pub mounts: Vec<MountConfig>,
}
/// A plan plus the working copy it will write to.
pub(crate) struct PreparedVm {
pub plan: Plan,
pub instance_dir: String,
pub work_rootfs: String,
/// Flush guest page cache to disk before stopping. Set for checkpoint
/// creation, where the disk is cloned after the hard stop and unsynced
/// writes would silently vanish from the saved image.
@@ -65,21 +77,21 @@ pub(crate) struct PreparedVm {
/// deletes nothing. Set only by plain `run` — never for checkpoint
/// creation or stdio mode, which read the disk back after the VM stops.
pub discard_disk: bool,
pub verbose: bool,
pub forwards: Vec<PortMapping>,
pub mounts: Vec<MountConfig>,
}
/// Prepare a VM instance. `discard` marks a throwaway disk (plain `run`):
/// it is unlinked while the VM holds it open, never read back, and on Linux
/// may live on tmpfs when the data dir cannot reflink.
pub(crate) fn prepare_vm(
vm: &VmArgs,
cfg: &Config,
from: Option<&str>,
discard: bool,
) -> Result<PreparedVm> {
trace_boot("prepare_vm start");
/// A prepared VM answers for its plan: `prepared.cpus` is the plan's, and
/// there is no second copy to drift from it.
impl std::ops::Deref for PreparedVm {
type Target = Plan;
fn deref(&self) -> &Plan {
&self.plan
}
}
/// Resolve a launch: flags over config over defaults, assets downloaded if
/// missing, the root image chosen. Touches no instance state.
pub(crate) fn plan(vm: &VmArgs, cfg: &Config, from: Option<&str>) -> Result<Plan> {
trace_boot("plan start");
let cpus = vm.cpus.or(cfg.cpus).unwrap_or(2);
let memory = vm.memory.or(cfg.memory).unwrap_or(2048);
let disk_size = vm.disk_size.or(cfg.disk_size).unwrap_or(4096);
@@ -220,7 +232,28 @@ pub(crate) fn prepare_vm(
}
};
trace_boot("prepare: config + assets checked");
trace_boot("plan done (config + assets checked)");
Ok(Plan {
source_rootfs: source,
cas_index,
kernel_path,
initrd_path,
cpus,
memory,
disk_size,
proxy_config,
verbose,
forwards,
mounts,
})
}
/// Prepare a VM instance from a plan. `discard` marks a throwaway disk (plain
/// `run`): it is unlinked while the VM holds it open, never read back, and on
/// Linux may live on tmpfs when the data dir cannot reflink.
pub(crate) fn prepare_vm(plan: Plan, discard: bool) -> Result<PreparedVm> {
let (verbose, disk_size) = (plan.verbose, plan.disk_size);
let data_dir = vm::default_data_dir();
// Create per-instance working copy (clean any stale dir from PID reuse)
let instance_dir = format!("{}/instances/{}", data_dir, std::process::id());
let _ = std::fs::remove_dir_all(&instance_dir);
@@ -231,11 +264,16 @@ pub(crate) fn prepare_vm(
// CAS checkpoints don't need a file copy — the NBD server reads from the chunk store.
// We still need a rootfs file for the VM builder (kernel cmdline root=), but it can be
// a dummy for CAS mode since I/O goes through NBD.
if cas_index.is_none() {
if plan.cas_index.is_none() {
if verbose {
eprintln!("hanzo-vm: creating working copy...");
}
clone_rootfs(&source, &mut work_rootfs, discard, disk_size * 1024 * 1024)?;
clone_rootfs(
&plan.source_rootfs,
&mut work_rootfs,
discard,
disk_size * 1024 * 1024,
)?;
// The clone is a fresh vnode: its pages are cold even when the source
// is cached, and the guest's root mount pays for that in disk reads.
// Warm it sequentially while the VM is being configured and started.
@@ -271,21 +309,11 @@ pub(crate) fn prepare_vm(
trace_boot("prepare_vm done (disk cloned + extended)");
Ok(PreparedVm {
plan,
instance_dir,
source_rootfs: source,
work_rootfs,
cas_index,
kernel_path,
initrd_path,
cpus,
memory,
disk_size,
proxy_config,
sync_before_stop: false,
discard_disk: discard,
verbose,
forwards,
mounts,
})
}
+9 -3
View File
@@ -7,6 +7,7 @@ use vm::default_data_dir;
use crate::boot;
use crate::cli::VmArgs;
use crate::config::load_config;
use crate::measure;
pub(crate) fn create(
name: String,
@@ -31,20 +32,25 @@ pub(crate) fn create(
bail!("checkpoint '{}' already exists, delete it first", name);
}
let mut prepared = boot::prepare_vm(vm_args, &cfg, from, false)?;
let mut prepared = boot::prepare_vm(boot::plan(vm_args, &cfg, from)?, false)?;
prepared.sync_before_stop = true;
let result = boot::run_command(&prepared, &command)?;
std::fs::create_dir_all(&checkpoints_dir)?;
eprintln!("hanzo-vm: saving checkpoint '{}'...", name);
if let Some(ref nbd_handle) = result.nbd_handle {
let saved = if let Some(ref nbd_handle) = result.nbd_handle {
let index_path = format!("{}/{}.idx", checkpoints_dir, name);
nbd_handle.save_checkpoint(&index_path)?;
index_path
} else {
let ext4_path = format!("{}/{}.ext4", checkpoints_dir, name);
boot::clone_file(&prepared.work_rootfs, &ext4_path)?;
}
ext4_path
};
// Measure it here, where the slow path has already been paid, so the boots
// that start from it read a digest instead of gigabytes.
measure::warm(&saved);
eprintln!("hanzo-vm: checkpoint '{}' saved", name);
drop(result.nbd_handle);
+15
View File
@@ -100,6 +100,21 @@ pub(crate) enum Commands {
command: Vec<String>,
},
/// Print what a boot would be: the measurement of its kernel, command
/// line, root image and shape, without booting anything
Measure {
#[command(flatten)]
vm: VmArgs,
/// Measure a named checkpoint instead of the base image
#[arg(long)]
from: Option<String>,
/// Read every image again instead of trusting remembered digests
#[arg(long)]
recompute: bool,
},
/// Download or update OS image assets
Init {
/// Force re-download even if assets exist
+25 -3
View File
@@ -3,6 +3,7 @@ mod boot;
mod checkpoint;
mod cli;
mod config;
mod measure;
mod stdio;
use std::process;
@@ -44,10 +45,21 @@ fn main() -> Result<()> {
vec!["/bin/sh".to_string()]
};
let prepared = boot::prepare_vm(&vm, &cfg, from.as_deref(), !stdio && !console)?;
let plan = boot::plan(&vm, &cfg, from.as_deref())?;
// What is about to be booted, stated before it is. Taken here,
// where the source images are still the only thing that exists,
// and only where something reads it: the stdio wire reports it to
// whatever is driving the vm. A command run in a vm and thrown
// away has nobody to tell.
let launch = if stdio {
Some(measure::launch(&plan, console, &mut measure::cache(false))?)
} else {
None
};
let prepared = boot::prepare_vm(plan, !stdio && !console)?;
let result = if stdio {
stdio::run_stdio(&prepared)
let result = if let Some(launch) = &launch {
stdio::run_stdio(&prepared, launch)
} else if console {
run_console(&prepared)
} else {
@@ -61,6 +73,16 @@ fn main() -> Result<()> {
boot::trace_boot("instance dir removed");
process::exit(result?);
}
Commands::Measure {
vm,
from,
recompute,
} => {
let cfg = load_config(vm.config.as_deref())?;
let plan = boot::plan(&vm, &cfg, from.as_deref())?;
let mut cache = measure::cache(recompute);
println!("{}", measure::document(&plan, false, &mut cache)?);
}
Commands::Init { force } => {
let data_dir = default_data_dir();
if force {
+75
View File
@@ -0,0 +1,75 @@
//! What this launch is, as a number.
//!
//! Everything the hypervisor is handed — the kernel, the command line, the
//! initramfs when there is one, the root image, the shape of the machine — is
//! measured into one register before the VM starts. The vm can only speak for
//! the launch: what the started system then runs is the caller's to extend
//! (`hanzo up` adds the workload it deploys), which is why this module
//! produces a [`Log`] and not a finished document.
//!
//! The root image measured is the SOURCE — the base image or the checkpoint —
//! never the per-instance working copy. The copy is a clone of the source
//! extended with zeros to `--disk-size` and is written to during the run; the
//! source is the thing two machines can compare.
use anyhow::Result;
use vm_measure::{Cache, Log, Measurement};
use crate::boot::Plan;
/// Where remembered file digests live: beside the images they describe.
pub(crate) fn cache(recompute: bool) -> Cache {
if recompute {
return Cache::none();
}
Cache::open(format!("{}/digests.json", vm::default_data_dir()))
}
/// The launch log for a planned VM. `console` completes the command line:
/// naming a console changes what the kernel is told, so it changes what the
/// guest is.
pub(crate) fn launch(plan: &Plan, console: bool, cache: &mut Cache) -> Result<Log> {
let mut log = Log::default();
log.file("kernel", plan.kernel_path.as_ref(), cache)?;
log.text("cmdline", &vm::command_line(plan.verbose, console));
if let Some(initrd) = &plan.initrd_path {
log.file("initrd", initrd.as_ref(), cache)?;
}
log.file("root", plan.source_rootfs.as_ref(), cache)?;
log.text("shape", &shape(plan));
cache.save();
Ok(log)
}
/// The machine the guest wakes up on. Measured because a platform that signs
/// for a launch signs for its vCPU count and memory too — a verifier that
/// checked only the software would accept the same kernel on a machine it
/// never agreed to.
fn shape(plan: &Plan) -> String {
format!(
"cpus={} memory={}MB disk={}MB",
plan.cpus, plan.memory, plan.disk_size
)
}
/// The whole document for a launch nothing has been deployed into yet.
///
/// `hardware` is `none` and stays `none`: a platform is asked from inside the
/// guest, and this states what a boot WOULD be — there is no guest to ask.
pub(crate) fn document(plan: &Plan, console: bool, cache: &mut Cache) -> Result<String> {
let m = Measurement {
launch: launch(plan, console, cache)?,
..Measurement::default()
};
Ok(m.to_json_pretty())
}
/// Remember a newly written image's digest while the slow path is already
/// running, so the boot that follows measures from the cache in microseconds
/// instead of reading gigabytes.
pub(crate) fn warm(path: &str) {
let mut cache = cache(false);
if cache.digest(path.as_ref()).is_ok() {
cache.save();
}
}
+39 -1
View File
@@ -29,6 +29,7 @@ mod method {
pub const RENAME: &str = "rename";
pub const COPY: &str = "copy";
pub const CHMOD: &str = "chmod";
pub const ATTEST: &str = "attest";
}
// JSON-RPC 2.0 error codes
@@ -172,6 +173,13 @@ struct CheckpointParams {
name: String,
}
/// The 64 bytes a hardware report is taken over, in the hex a measurement
/// document carries them in — `Measurement::bind_hex`.
#[derive(Deserialize)]
struct AttestParams {
bind: String,
}
#[derive(Deserialize)]
struct MkdirParams {
path: String,
@@ -311,7 +319,7 @@ fn send_error_shared(
)
}
pub(crate) fn run_stdio(prepared: &PreparedVm) -> Result<i32> {
pub(crate) fn run_stdio(prepared: &PreparedVm, launch: &vm_measure::Log) -> Result<i32> {
let out: SharedWriter = Arc::new(Mutex::new(io::stdout()));
// Set up proxy networking if --allow-net
@@ -361,6 +369,18 @@ pub(crate) fn run_stdio(prepared: &PreparedVm) -> Result<i32> {
// Event channel: background threads -> main loop
let (event_tx, event_rx) = std::sync::mpsc::channel::<Event>();
// What was booted, before anything is said about it running. The driver of
// this stdio wire is the one that knows what it then deploys, so it gets
// the launch half of the measurement and extends the rest itself.
send_json_shared(
&out,
&JsonRpcNotification {
jsonrpc: "2.0",
method: "measurement",
params: Some(launch),
},
)?;
// Send ready notification
send_json_shared(
&out,
@@ -713,6 +733,24 @@ pub(crate) fn run_stdio(prepared: &PreparedVm) -> Result<i32> {
return Ok(0);
}
// What the guest's platform will sign for. The bind covers the
// whole measurement, so the caller asks once the chain is
// complete — the vm has no opinion about when that is.
method::ATTEST => {
let bind = serde_json::from_value::<AttestParams>(req.params)
.map_err(anyhow::Error::from)
.and_then(|p| vm_measure::Measurement::parse_bind(&p.bind));
match bind {
Ok(bind) => match sandbox.attest(&bind) {
Ok(status) => send_result_shared(&out, req.id, status)?,
Err(e) => send_error_shared(&out, req.id, SERVER_ERROR, format!("{e}"))?,
},
Err(e) => {
send_error_shared(&out, req.id, INVALID_PARAMS, format!("{e}"))?;
}
}
}
method::MKDIR => {
let params: MkdirParams = match serde_json::from_value(req.params) {
Ok(p) => p,
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "vm-darwin"
version = "2.0.0"
version = "2.0.1"
edition = "2021"
description = "Virtualization.framework bindings for Hanzo VM"
license = "Apache-2.0"
+3 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "vm-guest"
version = "2.0.0"
version = "2.0.1"
edition = "2021"
description = "Guest init (PID 1) for Hanzo VM microVMs: mounts, vsock exec/fs/forward server"
license = "Apache-2.0"
@@ -11,7 +11,8 @@ name = "vm-guest"
path = "src/main.rs"
[dependencies]
vm-proto = { version = "2.0.0", path = "../vm-proto" }
vm-proto = { version = "2.0.1", path = "../vm-proto" }
vm-measure = { version = "2.0.1", path = "../vm-measure" }
libc = "0.2"
serde_json = "1"
ureq = { version = "3", default-features = false, features = ["rustls"] }
+16
View File
@@ -609,6 +609,22 @@ mod guest {
};
handle_download(&req, &mut writer);
}
frame::ATTEST_REQ => {
// 64 bytes, no more and no fewer: the payload IS the field a
// report is taken over, so a short one would be padded by
// the driver and a long one truncated — either way the
// report would answer for bytes nobody chose.
let Ok(bind) = <[u8; 64]>::try_from(payload.as_slice()) else {
let msg = format!("attest takes 64 bytes, got {}", payload.len());
let _ = frame::write_frame(&mut writer, frame::ERROR, msg.as_bytes());
continue;
};
let _ = frame::send_json(
&mut writer,
frame::ATTEST_RESP,
&vm_measure::attest::status(&bind),
);
}
_ => {} // unknown type, skip
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "vm-linux"
version = "2.0.0"
version = "2.0.1"
edition = "2021"
description = "KVM backend for Hanzo VM"
license = "Apache-2.0"
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "vm-measure"
version = "2.0.1"
edition = "2021"
description = "Measured boot for the Hanzo microVM: an extend-only register over what was loaded"
license = "Apache-2.0"
repository = "https://github.com/hanzoai/vm"
[dependencies]
anyhow = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
[target.'cfg(target_os = "linux")'.dependencies]
libc = "0.2"
[dev-dependencies]
tempfile = "3"
+313
View File
@@ -0,0 +1,313 @@
//! Binding a measurement to hardware.
//!
//! A measurement computed by a launcher says what the launcher loaded. It is
//! reproducible, and it is worth exactly as much as the machine that produced
//! it. To turn it into evidence, the same 64 bytes have to come back out of a
//! chip that the host cannot impersonate.
//!
//! Both shipping confidential-computing platforms offer the same primitive: a
//! guest asks its firmware for a report over 64 caller-supplied bytes, and the
//! report also carries the platform's OWN measurement of how that guest was
//! started. Put [`Measurement::bind`](crate::Measurement::bind) in the caller
//! field and a verifier gets two facts in one signature: this guest holds this
//! measurement log, and this guest was launched from this image. The first is
//! what a log alone cannot establish; the second is what a log cannot even
//! observe.
//!
//! The request is made from INSIDE the guest — `/dev/sev-guest` and
//! `/dev/tdx_guest` are guest devices, present only when the guest is running
//! under SEV-SNP or TDX. So [`status`] is called by `vm-guest`, which reaches
//! it over vsock: the host sends the 64 bytes as an `ATTEST_REQ` frame and the
//! guest answers with a [`Status`]. Asking the HOST would answer a different
//! question — whether the machine running the launcher is itself somebody's
//! confidential guest — and a report over our bind from there says nothing
//! about the VM we started.
//!
//! What is implemented here: platform detection, the two request ABIs, and the
//! ioctls that carry them. What is NOT exercised: the ioctls themselves, for
//! want of the hardware — see this module's tests, which pin the structure
//! sizes and ioctl encodings against the published kernel headers, and the
//! specification for the parts a verifier still has to supply (the VCEK or
//! quote chain, and an expected launch digest to compare the platform's own
//! measurement against).
//!
//! Hardware that would exercise it: AMD EPYC (Milan or later) with SEV-SNP
//! enabled in firmware and a VMM that starts SNP guests, or Intel Xeon with
//! TDX enabled and a TDX-aware VMM. AMD's client parts, Apple silicon and
//! NVIDIA's Grace-Blackwell workstation parts offer no equivalent to a
//! third-party VMM; none of the three machines this was built on can run the
//! path, so on all three [`platform`] answers `None` and a document records
//! `none`.
use anyhow::{bail, Result};
use serde::{Deserialize, Serialize};
/// A confidential-computing platform that will sign a report for its guest.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Platform {
/// AMD SEV-SNP. The report is signed by the VCEK and is verifiable off the
/// machine against AMD's key distribution service.
SevSnp,
/// Intel TDX. The ioctl returns a TDREPORT — locally verifiable by the
/// quoting enclave, which converts it into a remotely verifiable quote.
Tdx,
}
impl Platform {
pub fn device(self) -> &'static str {
match self {
Platform::SevSnp => "/dev/sev-guest",
Platform::Tdx => "/dev/tdx_guest",
}
}
pub fn label(self) -> &'static str {
match self {
Platform::SevSnp => "sev-snp",
Platform::Tdx => "tdx",
}
}
}
/// Which platform this machine can produce a report from, if any. Presence of
/// the guest device is the whole test: the driver binds only inside a guest
/// the platform actually protects.
pub fn platform() -> Option<Platform> {
[Platform::SevSnp, Platform::Tdx]
.into_iter()
.find(|p| std::path::Path::new(p.device()).exists())
}
/// What a measurement document records about the machine it was made on:
/// which platform signed for it, and the report, if any.
///
/// A report is an OBSERVATION attached to a measurement, never part of it. The
/// check a verifier owes it is one line: the report's REPORT_DATA field must
/// equal the measurement's bind. A report whose caller field says something
/// else is a report about a different measurement.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Status {
/// `none`, `sev-snp` or `tdx`.
pub platform: String,
/// The raw report, hex-encoded, when there is one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub report: Option<String>,
}
impl Default for Status {
fn default() -> Status {
Status {
platform: "none".to_string(),
report: None,
}
}
}
/// Ask this machine's platform for a report over `bind`, when it has one. A
/// device that will not answer is not a platform we can claim: the status
/// falls back to `none` rather than asserting hardware that produced nothing.
pub fn status(bind: &[u8; 64]) -> Status {
match platform().map(|p| (p, report(p, bind))) {
Some((p, Ok(bytes))) => Status {
platform: p.label().to_string(),
report: Some(bytes.iter().map(|b| format!("{b:02x}")).collect()),
},
Some((_, Err(_))) | None => Status::default(),
}
}
/// The raw report bytes: an SEV-SNP attestation report (1184 bytes) or a TDX
/// TDREPORT (1024 bytes), exactly as the platform produced them. Parsing them
/// belongs to the verifier, not to the thing that asked.
pub fn report(platform: Platform, bind: &[u8; 64]) -> Result<Vec<u8>> {
match platform {
Platform::SevSnp => snp_report(bind),
Platform::Tdx => tdx_report(bind),
}
}
/// The kernel ABIs — `linux/sev-guest.h` and `linux/tdx-guest.h`.
///
/// Compiled wherever the tests run, not only where the call can be made: an
/// ioctl number encodes the size of the structure it carries, so a structure
/// that drifts sends a number no driver answers. That is the one part of this
/// path a machine without the hardware can still check.
#[cfg(any(target_os = "linux", test))]
mod abi {
/// `struct snp_report_req`: the 64 bytes to sign, the VMPL to sign at.
#[repr(C)]
pub struct SnpRequest {
pub user_data: [u8; 64],
pub vmpl: u32,
pub reserved: [u8; 28],
}
/// `struct snp_guest_request_ioctl`: the message version, addresses of the
/// request and response buffers, and the firmware's error on failure.
#[repr(C)]
pub struct SnpMessage {
pub version: u8,
pub request: u64,
pub response: u64,
pub error: u64,
}
/// `struct snp_report_resp`: a fixed 4000-byte buffer. The attestation
/// report begins after the status word, its length and the reserved bytes.
pub const SNP_RESPONSE: usize = 4000;
pub const SNP_REPORT_OFFSET: usize = 32;
pub const SNP_REPORT_LENGTH: usize = 1184;
pub const TDX_REPORT_LENGTH: usize = 1024;
/// `struct tdx_report_req`: the 64 bytes to sign, and the buffer the
/// TDREPORT lands in.
#[repr(C)]
pub struct TdxRequest {
pub report_data: [u8; 64],
pub report: [u8; TDX_REPORT_LENGTH],
}
/// `_IOWR(type, nr, size)` — the encoding both numbers below are built
/// from, so neither is a constant copied out of a header. An ioctl number
/// is 32 bits wide; how it is then passed is the platform's business
/// (glibc takes an unsigned long, musl a signed int), so the width is
/// fixed here and the cast is made at the call.
const fn iowr(kind: u8, nr: u8, size: usize) -> u32 {
const READ_WRITE: u32 = 3;
(READ_WRITE << 30) | ((size as u32) << 16) | ((kind as u32) << 8) | nr as u32
}
/// `SNP_GET_REPORT = _IOWR('S', 0x0, struct snp_guest_request_ioctl)`
pub const SNP_GET_REPORT: u32 = iowr(b'S', 0, std::mem::size_of::<SnpMessage>());
/// `TDX_CMD_GET_REPORT0 = _IOWR('T', 1, struct tdx_report_req)`
pub const TDX_GET_REPORT: u32 = iowr(b'T', 1, std::mem::size_of::<TdxRequest>());
}
#[cfg(any(target_os = "linux", test))]
use abi::*;
#[cfg(target_os = "linux")]
fn snp_report(bind: &[u8; 64]) -> Result<Vec<u8>> {
use std::os::fd::AsRawFd;
let device = std::fs::File::open(Platform::SevSnp.device())?;
let request = SnpRequest {
user_data: *bind,
vmpl: 0,
reserved: [0; 28],
};
let mut response = vec![0u8; SNP_RESPONSE];
let mut message = SnpMessage {
version: 1,
request: &request as *const SnpRequest as u64,
response: response.as_mut_ptr() as u64,
error: 0,
};
// SAFETY: the driver reads `message`, and through it a request of the size
// the ioctl number declares and a response buffer of the size the ABI
// fixes. Both outlive the call.
let rc = unsafe {
libc::ioctl(
device.as_raw_fd(),
SNP_GET_REPORT as libc::Ioctl,
&mut message as *mut SnpMessage,
)
};
if rc != 0 {
bail!(
"SNP_GET_REPORT: {} (firmware error {:#x})",
std::io::Error::last_os_error(),
message.error
);
}
let end = SNP_REPORT_OFFSET + SNP_REPORT_LENGTH;
Ok(response[SNP_REPORT_OFFSET..end].to_vec())
}
#[cfg(target_os = "linux")]
fn tdx_report(bind: &[u8; 64]) -> Result<Vec<u8>> {
use std::os::fd::AsRawFd;
let device = std::fs::File::open(Platform::Tdx.device())?;
let mut request = TdxRequest {
report_data: *bind,
report: [0; TDX_REPORT_LENGTH],
};
// SAFETY: as above — one structure, the size the ioctl number declares.
let rc = unsafe {
libc::ioctl(
device.as_raw_fd(),
TDX_GET_REPORT as libc::Ioctl,
&mut request as *mut TdxRequest,
)
};
if rc != 0 {
bail!("TDX_CMD_GET_REPORT0: {}", std::io::Error::last_os_error());
}
Ok(request.report.to_vec())
}
#[cfg(not(target_os = "linux"))]
fn snp_report(_bind: &[u8; 64]) -> Result<Vec<u8>> {
bail!("SEV-SNP reports come from a Linux guest device")
}
#[cfg(not(target_os = "linux"))]
fn tdx_report(_bind: &[u8; 64]) -> Result<Vec<u8>> {
bail!("TDX reports come from a Linux guest device")
}
#[cfg(test)]
mod tests {
use super::*;
/// The request structures are the sizes the kernel headers define. The
/// ioctl number encodes that size, so a structure that drifts would send a
/// number no driver answers — this is the check the missing hardware
/// cannot perform for us.
#[test]
fn the_request_structures_match_the_kernel_abi() {
assert_eq!(std::mem::size_of::<SnpRequest>(), 96);
assert_eq!(std::mem::size_of::<SnpMessage>(), 32);
assert_eq!(std::mem::size_of::<TdxRequest>(), 1088);
assert_eq!(std::mem::size_of::<TdxRequest>(), 64 + TDX_REPORT_LENGTH);
// The report we slice out has to lie inside the buffer the driver fills.
let end = SNP_REPORT_OFFSET + SNP_REPORT_LENGTH;
assert_eq!(
end.min(SNP_RESPONSE),
end,
"the report overruns the response"
);
}
/// The encoding, against the numbers `_IOWR` produces for these three
/// arguments (they appear as literals in driver documentation and in every
/// strace of a guest attestation).
#[test]
fn the_ioctl_numbers_are_the_documented_ones() {
assert_eq!(SNP_GET_REPORT, 0xC020_5300);
assert_eq!(TDX_GET_REPORT, 0xC440_5401);
}
/// Detection is device presence, and on a machine without one the status
/// says so rather than inventing a platform.
#[test]
fn a_machine_without_the_device_reports_none() {
if platform().is_none() {
assert_eq!(status(&[0u8; 64]), Status::default());
assert_eq!(Status::default().platform, "none");
assert!(Status::default().report.is_none());
}
}
#[test]
fn a_platform_names_its_device() {
assert_eq!(Platform::SevSnp.device(), "/dev/sev-guest");
assert_eq!(Platform::Tdx.device(), "/dev/tdx_guest");
assert_eq!(Platform::SevSnp.label(), "sev-snp");
assert_eq!(Platform::Tdx.label(), "tdx");
}
}
+659
View File
@@ -0,0 +1,659 @@
//! What a VM is, stated as a number.
//!
//! A measurement is an ordered log of the things a launch loaded — the kernel,
//! the command line it was given, the root image, the workload — folded into a
//! single extend-only register:
//!
//! ```text
//! e_i = SHA-384( name_i ‖ 0x00 ‖ SHA-384(content_i) )
//! R_0 = 0…0 (48 zero bytes)
//! R_{i+1} = SHA-384( R_i ‖ e_i )
//! ```
//!
//! The fold is the extend rule Intel TDX applies to a runtime measurement
//! register (RTMR := SHA-384(RTMR ‖ 48 bytes)), so a register built here
//! reproduces an RTMR that started at zero and was extended with the same
//! events, in the same order. SHA-384 is not a taste: it is the digest both
//! TDX registers and the AMD SEV-SNP launch measurement use, so nothing has to
//! be re-hashed at the boundary to hardware.
//!
//! Two properties follow from the shape and are the whole point:
//!
//! An event's NAME is inside its own digest, so an event cannot be replayed
//! under a different name, and the chain commits to order — a prefix of a log
//! and a whole log are different registers.
//!
//! Only content is hashed. The PATH a file was read from is recorded for the
//! reader and never enters the digest, so the same kernel and the same root
//! image measure identically on every machine that has them. A measurement is
//! reproducible by anyone holding the assets; it is not a fingerprint of one
//! host's filesystem.
//!
//! What this is NOT, stated once: computed on the host, a measurement is a
//! statement about what was launched, not evidence against the host that
//! launched it. A host that can substitute the kernel can also substitute the
//! program that hashes it. It becomes evidence when the register is bound to a
//! report signed by hardware the host does not control — see [`attest`].
use std::collections::BTreeMap;
use std::io::Read;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use sha2::{Digest as _, Sha384, Sha512};
pub mod attest;
/// The digest every register, event and file digest is taken with.
pub const ALGORITHM: &str = "sha384";
/// SHA-384 output, in bytes.
pub const LENGTH: usize = 48;
/// A SHA-384 digest: a register value, an event digest, a file digest. Carried
/// as bytes, written as hex.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct Digest([u8; LENGTH]);
impl Digest {
pub fn of(bytes: &[u8]) -> Digest {
Digest(Sha384::digest(bytes).into())
}
/// The register before anything is extended into it.
pub fn zero() -> Digest {
Digest([0u8; LENGTH])
}
pub fn bytes(&self) -> &[u8; LENGTH] {
&self.0
}
pub fn hex(&self) -> String {
hex(&self.0)
}
pub fn parse(s: &str) -> Result<Digest> {
let bytes = unhex(s).with_context(|| format!("not a hex digest: {s}"))?;
let bytes: [u8; LENGTH] = bytes
.try_into()
.map_err(|_| anyhow::anyhow!("a {ALGORITHM} digest is {LENGTH} bytes"))?;
Ok(Digest(bytes))
}
}
impl std::fmt::Display for Digest {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.hex())
}
}
impl Serialize for Digest {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&self.hex())
}
}
impl<'de> Deserialize<'de> for Digest {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
Digest::parse(&s).map_err(serde::de::Error::custom)
}
}
/// One measured thing.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Event {
/// What was loaded: `kernel`, `cmdline`, `root`, `image`, `manifest`.
pub name: String,
/// Where the content came from — a filesystem path, or the text itself
/// when the content IS text. For the reader; never hashed.
pub source: String,
/// SHA-384 of the content.
pub digest: Digest,
}
impl Event {
/// The 48 bytes the register extends with. The name is inside, so two
/// events cannot trade places or masquerade as one another.
pub fn extension(&self) -> Digest {
let mut h = Sha384::new();
h.update(self.name.as_bytes());
h.update([0u8]);
h.update(self.digest.0);
Digest(h.finalize().into())
}
}
/// An ordered log and, by folding it, one register value. The register is
/// derived, never stored: there is no way for the two to disagree.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Log {
events: Vec<Event>,
}
impl Log {
pub fn events(&self) -> &[Event] {
&self.events
}
pub fn is_empty(&self) -> bool {
self.events.is_empty()
}
pub fn push(&mut self, event: Event) {
self.events.push(event);
}
/// Measure bytes the caller holds, saying where they came from or are
/// going. `source` is for the reader and never enters the digest.
pub fn bytes(&mut self, name: &str, source: &str, content: &[u8]) {
self.push(Event {
name: name.to_string(),
source: source.to_string(),
digest: Digest::of(content),
});
}
/// Measure text — a kernel command line, an image reference, the VM's
/// shape. The text is both the content and the source.
pub fn text(&mut self, name: &str, value: &str) {
self.bytes(name, value, value.as_bytes());
}
/// Measure a file's bytes, through `cache` — kernels and root images are
/// hundreds of megabytes and do not change between boots.
pub fn file(&mut self, name: &str, path: &Path, cache: &mut Cache) -> Result<()> {
let digest = cache.digest(path)?;
self.push(Event {
name: name.to_string(),
source: path.display().to_string(),
digest,
});
Ok(())
}
/// Fold the log: `R_{i+1} = SHA-384(R_i ‖ e_i)`, from zero.
pub fn register(&self) -> Digest {
self.events.iter().fold(Digest::zero(), |r, e| {
let mut h = Sha384::new();
h.update(r.0);
h.update(e.extension().0);
Digest(h.finalize().into())
})
}
}
/// The wire shape of a log: the register value AND the events that produce it.
/// Written for a reader who wants the number without folding; verified on the
/// way back in, so a log whose value was edited is refused rather than trusted.
#[derive(Serialize, Deserialize)]
struct LogWire {
value: Digest,
events: Vec<Event>,
}
impl Serialize for Log {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
LogWire {
value: self.register(),
events: self.events.clone(),
}
.serialize(s)
}
}
impl<'de> Deserialize<'de> for Log {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let wire = LogWire::deserialize(d)?;
let log = Log {
events: wire.events,
};
if log.register() != wire.value {
return Err(serde::de::Error::custom(format!(
"log value {} does not fold from its events",
wire.value
)));
}
Ok(log)
}
}
/// A launch and what it was asked to run, kept apart.
///
/// `launch` is what the hypervisor was handed: kernel, command line, root
/// image, shape. `workload` is what the started system was told to run. The
/// split is not decoration — the two are extended by different programs at
/// different times (the VM launcher, then whatever drives it), and on TDX they
/// map onto separate runtime registers. Folding them into one chain would lose
/// which half a verifier is entitled to predict.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Measurement {
pub launch: Log,
pub workload: Log,
/// What the guest's platform had to say for itself, asked once the chain
/// is complete. An observation carried alongside the chain, never folded
/// into it — see [`attest::Status`].
pub hardware: attest::Status,
}
impl Measurement {
/// The 64 bytes a hardware report carries as REPORT_DATA:
/// `SHA-512(launch ‖ workload)`. SHA-512 because the field is 64 bytes and
/// a report that pads is a report where the padding is unaccounted for.
pub fn bind(&self) -> [u8; 64] {
let mut h = Sha512::new();
h.update(self.launch.register().0);
h.update(self.workload.register().0);
h.finalize().into()
}
pub fn bind_hex(&self) -> String {
hex(&self.bind())
}
/// A bind read back from the hex it travels in — the inverse of
/// [`Measurement::bind_hex`], for the wire that carries one to whatever
/// will ask a platform to sign over it.
pub fn parse_bind(s: &str) -> Result<[u8; 64]> {
unhex(s)
.and_then(|b| <[u8; 64]>::try_from(b).ok())
.ok_or_else(|| anyhow::anyhow!("a bind is 64 bytes of hex"))
}
pub fn to_json(&self) -> String {
serde_json::to_string(&MeasurementWire::from(self)).expect("a measurement serializes")
}
pub fn to_json_pretty(&self) -> String {
serde_json::to_string_pretty(&MeasurementWire::from(self))
.expect("a measurement serializes")
}
pub fn from_json(s: &str) -> Result<Measurement> {
let wire: MeasurementWire = serde_json::from_str(s).context("reading a measurement")?;
if wire.algorithm != ALGORITHM {
bail!(
"measurement algorithm {} is not {ALGORITHM}",
wire.algorithm
);
}
let m = Measurement {
launch: wire.launch,
workload: wire.workload,
hardware: wire.hardware,
};
if m.bind_hex() != wire.bind {
bail!("measurement bind does not follow from its registers");
}
Ok(m)
}
}
#[derive(Serialize, Deserialize)]
struct MeasurementWire {
algorithm: String,
launch: Log,
workload: Log,
bind: String,
#[serde(default)]
hardware: attest::Status,
}
impl From<&Measurement> for MeasurementWire {
fn from(m: &Measurement) -> MeasurementWire {
MeasurementWire {
algorithm: ALGORITHM.to_string(),
launch: m.launch.clone(),
workload: m.workload.clone(),
bind: m.bind_hex(),
hardware: m.hardware.clone(),
}
}
}
// ---- file digests -------------------------------------------------------------
/// Remembered file digests, keyed by the file's identity.
///
/// The root image is a gigabyte and the checkpoint four; hashing them on every
/// boot would cost more than the boot. A digest is kept against the identity
/// the filesystem reports — device, inode, length, modification time — and
/// recomputed the moment any of those move. The cache is a host-local
/// convenience and carries no authority: it lives on the same disk as the
/// image it describes, so an attacker who can rewrite one can rewrite the
/// other. It saves time; it proves nothing. [`Cache::none`] skips it.
pub struct Cache {
path: Option<PathBuf>,
entries: BTreeMap<String, Entry>,
dirty: bool,
}
#[derive(Clone, Serialize, Deserialize)]
struct Entry {
device: u64,
inode: u64,
length: u64,
modified: i64,
modified_nanos: i64,
digest: Digest,
}
impl Cache {
/// Open the cache at `path`. A missing or unreadable file is an empty
/// cache, never an error: a measurement that cannot be looked up is
/// computed.
pub fn open(path: impl Into<PathBuf>) -> Cache {
let path = path.into();
let entries = std::fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
Cache {
path: Some(path),
entries,
dirty: false,
}
}
/// A cache that remembers nothing: every digest is computed from the file.
pub fn none() -> Cache {
Cache {
path: None,
entries: BTreeMap::new(),
dirty: false,
}
}
pub fn digest(&mut self, path: &Path) -> Result<Digest> {
let identity = identity(path)?;
let key = path.display().to_string();
if let Some(hit) = self.entries.get(&key) {
if hit.device == identity.device
&& hit.inode == identity.inode
&& hit.length == identity.length
&& hit.modified == identity.modified
&& hit.modified_nanos == identity.modified_nanos
{
return Ok(hit.digest);
}
}
let digest = digest_file(path)?;
self.entries.insert(key, Entry { digest, ..identity });
self.dirty = true;
Ok(digest)
}
/// Persist, atomically, when there is anything new to persist. Best
/// effort: a cache we could not write is a slower next boot, not a failed
/// one.
pub fn save(&mut self) {
let (Some(path), true) = (self.path.as_ref(), self.dirty) else {
return;
};
let Ok(json) = serde_json::to_string(&self.entries) else {
return;
};
let tmp = path.with_extension(format!("tmp.{}", std::process::id()));
if std::fs::write(&tmp, json).is_ok() && std::fs::rename(&tmp, path).is_ok() {
self.dirty = false;
} else {
let _ = std::fs::remove_file(&tmp);
}
}
}
/// SHA-384 of a file's bytes, read a megabyte at a time.
pub fn digest_file(path: &Path) -> Result<Digest> {
let file =
std::fs::File::open(path).with_context(|| format!("measuring {}", path.display()))?;
let mut reader = std::io::BufReader::with_capacity(1 << 20, file);
let mut hasher = Sha384::new();
let mut buf = vec![0u8; 1 << 20];
loop {
let n = reader
.read(&mut buf)
.with_context(|| format!("reading {}", path.display()))?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
Ok(Digest(hasher.finalize().into()))
}
#[cfg(unix)]
fn identity(path: &Path) -> Result<Entry> {
use std::os::unix::fs::MetadataExt;
let meta = std::fs::metadata(path).with_context(|| format!("measuring {}", path.display()))?;
Ok(Entry {
device: meta.dev(),
inode: meta.ino(),
length: meta.len(),
modified: meta.mtime(),
modified_nanos: meta.mtime_nsec(),
digest: Digest::zero(),
})
}
// ---- hex ----------------------------------------------------------------------
fn hex(bytes: &[u8]) -> String {
use std::fmt::Write as _;
bytes.iter().fold(String::new(), |mut s, b| {
let _ = write!(s, "{b:02x}");
s
})
}
fn unhex(s: &str) -> Option<Vec<u8>> {
if !s.len().is_multiple_of(2) {
return None;
}
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
/// SHA-384 of the empty string, from FIPS 180-4 — the algorithm is the one
/// it claims to be.
#[test]
fn the_digest_is_sha384() {
assert_eq!(
Digest::of(b"").hex(),
"38b060a751ac96384cd9327eb1b1e36a21fdb71114be07434c0cc7bf63f6e1da\
274edebfe76f65fbd51ad2f14898b95b"
);
assert_eq!(Digest::of(b"").bytes().len(), LENGTH);
}
#[test]
fn a_digest_round_trips_through_hex() {
let d = Digest::of(b"kernel");
assert_eq!(Digest::parse(&d.hex()).unwrap(), d);
assert!(Digest::parse("not hex").is_err());
assert!(Digest::parse("ab").is_err(), "wrong length is refused");
}
/// The fold is exactly SHA-384(R ‖ e), from zero — computed here by hand
/// so the rule is pinned by a test and not only by the implementation.
#[test]
fn the_register_folds_the_log() {
let mut log = Log::default();
assert_eq!(log.register(), Digest::zero(), "nothing measured is zero");
log.text("cmdline", "root=/dev/vda rw");
let e = log.events()[0].extension();
let mut h = Sha384::new();
h.update(Digest::zero().0);
h.update(e.0);
assert_eq!(log.register().hex(), hex(&h.finalize()));
}
/// An event's name is inside its extension, so the same bytes under a
/// different name are a different measurement.
#[test]
fn the_name_is_part_of_the_event() {
let mut a = Log::default();
a.text("kernel", "same bytes");
let mut b = Log::default();
b.text("initrd", "same bytes");
assert_ne!(a.register(), b.register());
}
/// Order is committed to: swapping two events changes the register.
#[test]
fn the_register_commits_to_order() {
let mut a = Log::default();
a.text("kernel", "K");
a.text("root", "R");
let mut b = Log::default();
b.text("root", "R");
b.text("kernel", "K");
assert_ne!(a.register(), b.register());
}
/// A prefix is not the whole: appending always moves the register.
#[test]
fn extending_moves_the_register() {
let mut log = Log::default();
log.text("kernel", "K");
let before = log.register();
log.text("root", "R");
assert_ne!(before, log.register());
}
/// Bytes measure as their content; where they came from does not enter
/// the digest, so text and the same text as bytes are one event.
#[test]
fn bytes_measure_their_content_not_their_source() {
let mut a = Log::default();
a.bytes(
"manifest",
"/var/lib/rancher/k3s/server/manifests/cloud.yaml",
b"kind: Deployment",
);
let mut b = Log::default();
b.bytes("manifest", "somewhere else entirely", b"kind: Deployment");
assert_eq!(a.register(), b.register());
assert_ne!(a.events()[0].source, b.events()[0].source);
let mut t = Log::default();
t.text("cmdline", "root=/dev/vda");
let mut y = Log::default();
y.bytes("cmdline", "root=/dev/vda", b"root=/dev/vda");
assert_eq!(t.events(), y.events());
}
/// The path a file came from is not measured — the same content at two
/// paths is one measurement, so a measurement is reproducible off-host.
#[test]
fn the_path_is_not_measured() {
let dir = tempfile::tempdir().unwrap();
let (a, b) = (dir.path().join("one"), dir.path().join("two"));
std::fs::write(&a, b"identical").unwrap();
std::fs::write(&b, b"identical").unwrap();
let mut cache = Cache::none();
let mut first = Log::default();
first.file("root", &a, &mut cache).unwrap();
let mut second = Log::default();
second.file("root", &b, &mut cache).unwrap();
assert_eq!(first.register(), second.register());
assert_ne!(first.events()[0].source, second.events()[0].source);
}
#[test]
fn a_file_measures_its_bytes() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("Image");
// Two buffers' worth plus a tail, so the streaming read is exercised.
let bytes: Vec<u8> = (0..(2 * (1 << 20) + 7)).map(|i| (i % 251) as u8).collect();
std::fs::write(&path, &bytes).unwrap();
assert_eq!(digest_file(&path).unwrap(), Digest::of(&bytes));
}
/// The cache answers from the file's identity, and recomputes when the
/// file behind the path changes.
#[test]
fn the_cache_follows_the_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("rootfs.ext4");
std::fs::write(&path, b"first").unwrap();
let mut cache = Cache::open(dir.path().join("digests.json"));
let first = cache.digest(&path).unwrap();
assert_eq!(first, Digest::of(b"first"));
assert_eq!(cache.digest(&path).unwrap(), first, "a hit is the same");
cache.save();
// A reopened cache still knows it, without touching the file's bytes.
let mut reopened = Cache::open(dir.path().join("digests.json"));
assert_eq!(reopened.digest(&path).unwrap(), first);
std::fs::write(&path, b"second image").unwrap();
assert_eq!(reopened.digest(&path).unwrap(), Digest::of(b"second image"));
}
#[test]
fn a_missing_file_is_an_error_not_a_zero() {
let mut cache = Cache::none();
assert!(cache.digest(Path::new("/nonexistent/Image")).is_err());
}
/// The document carries its registers and its bind, and reading it back
/// verifies both — an edited value is refused, not believed.
#[test]
fn a_measurement_round_trips_and_is_verified() {
let mut m = Measurement::default();
m.launch.text("cmdline", "root=/dev/vda rw quiet");
m.workload.text("image", "ghcr.io/hanzoai/cloud@sha256:00");
let json = m.to_json();
assert_eq!(Measurement::from_json(&json).unwrap(), m);
let tampered = json.replace(&m.launch.register().hex(), &Digest::of(b"lie").hex());
assert!(Measurement::from_json(&tampered).is_err());
let rebound = json.replace(&m.bind_hex(), &hex(&[7u8; 64]));
let err = Measurement::from_json(&rebound).unwrap_err();
assert!(err.to_string().contains("bind"), "{err}");
}
/// The bind is 64 bytes and moves with either half.
#[test]
fn the_bind_covers_both_registers() {
let mut m = Measurement::default();
m.launch.text("kernel", "K");
let launch_only = m.bind();
assert_eq!(launch_only.len(), 64);
m.workload.text("image", "I");
assert_ne!(m.bind(), launch_only);
}
/// A bind survives the wire that carries it to whatever asks a platform to
/// sign, and anything that is not 64 bytes of hex is refused there rather
/// than padded into a report over bytes nobody chose.
#[test]
fn a_bind_round_trips_through_hex() {
let mut m = Measurement::default();
m.launch.text("kernel", "K");
assert_eq!(Measurement::parse_bind(&m.bind_hex()).unwrap(), m.bind());
assert!(Measurement::parse_bind("").is_err());
assert!(Measurement::parse_bind(&"ab".repeat(63)).is_err(), "short");
assert!(Measurement::parse_bind(&"ab".repeat(65)).is_err(), "long");
assert!(
Measurement::parse_bind(&"zz".repeat(64)).is_err(),
"not hex"
);
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "vm-proto"
version = "2.0.0"
version = "2.0.1"
edition = "2021"
description = "Shared wire protocol for Hanzo VM host/guest communication"
license = "Apache-2.0"
+7
View File
@@ -53,6 +53,13 @@ pub const DISCARD_RESP: u8 = 0x4F;
pub const DOWNLOAD_REQ: u8 = 0x50;
pub const DOWNLOAD_PROGRESS: u8 = 0x51;
// Attestation. The request payload is 64 raw bytes and nothing else — the
// figure a hardware report is taken over. It carries no structure because the
// guest gives it none: it hands the bytes to the platform and hands back what
// the platform said. The response is a JSON `vm_measure::attest::Status`.
pub const ATTEST_REQ: u8 = 0x60;
pub const ATTEST_RESP: u8 = 0x61;
const MAX_FRAME: u32 = 1 << 20; // 1 MB
/// Write a binary frame: `[u32 BE length][u8 type][payload]`.
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "vm-proxy"
version = "2.0.0"
version = "2.0.1"
edition = "2021"
description = "Transparent network proxy for Hanzo VM microVMs"
license = "Apache-2.0"
+5 -5
View File
@@ -1,6 +1,6 @@
[package]
name = "vm-sdk"
version = "2.0.0"
version = "2.0.1"
edition = "2021"
description = "Async Rust SDK for Hanzo VM microVMs"
license = "Apache-2.0"
@@ -11,10 +11,10 @@ default = []
cas = ["vm-store", "blake3"]
[dependencies]
vm-core = { version = "2.0.0", path = "../vm" }
vm-proto = { version = "2.0.0", path = "../vm-proto" }
vm-proxy = { version = "2.0.0", path = "../vm-proxy" }
vm-store = { version = "2.0.0", path = "../vm-store", optional = true }
vm-core = { version = "2.0.1", path = "../vm" }
vm-proto = { version = "2.0.1", path = "../vm-proto" }
vm-proxy = { version = "2.0.1", path = "../vm-proxy" }
vm-store = { version = "2.0.1", path = "../vm-store", optional = true }
tokio = { version = "1", features = ["sync"] }
anyhow = "1"
blake3 = { version = "1", optional = true }
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "vm-store"
version = "2.0.0"
version = "2.0.1"
edition = "2021"
description = "NBD-backed storage layer for Hanzo VM microVMs"
license = "Apache-2.0"
+6 -5
View File
@@ -1,6 +1,6 @@
[package]
name = "vm-core"
version = "2.0.0"
version = "2.0.1"
edition = "2021"
description = "Hanzo microVM: boot, exec, mounts, ports, checkpoints"
license = "Apache-2.0"
@@ -10,7 +10,8 @@ repository = "https://github.com/hanzoai/vm"
name = "vm"
[dependencies]
vm-proto = { version = "2.0.0", path = "../vm-proto" }
vm-proto = { version = "2.0.1", path = "../vm-proto" }
vm-measure = { version = "2.0.1", path = "../vm-measure" }
anyhow = "1"
crossbeam-channel = "0.5"
serde = { version = "1", features = ["derive"] }
@@ -21,8 +22,8 @@ socket2 = "0.5"
[target.'cfg(target_os = "linux")'.dependencies]
libc = "0.2"
[target.'cfg(target_os = "macos")'.dependencies]
vm-darwin = { version = "2.0.0", path = "../vm-darwin" }
vm-darwin = { version = "2.0.1", path = "../vm-darwin" }
[target.'cfg(all(target_os = "linux", target_arch = "aarch64"))'.dependencies]
vm-linux = { version = "2.0.0", path = "../vm-linux" }
vm-linux = { version = "2.0.1", path = "../vm-linux" }
[target.'cfg(all(target_os = "linux", target_arch = "x86_64"))'.dependencies]
vm-ch = { version = "2.0.0", path = "../vm-ch" }
vm-ch = { version = "2.0.1", path = "../vm-ch" }
+1 -1
View File
@@ -7,7 +7,7 @@ mod sandbox;
#[cfg(target_os = "linux")]
pub use clone::{clone_file, reflink_file};
pub use sandbox::{MountConfig, PortForwardHandle, Sandbox, VmConfigBuilder};
pub use sandbox::{command_line, MountConfig, PortForwardHandle, Sandbox, VmConfigBuilder};
pub use vm_proto::{
frame, ExecRequest, ForwardRequest, ForwardResponse, MountRequest, MountResponse, PortMapping,
ReadFileRequest, WriteFileRequest, WriteFileResponse, VSOCK_PORT, VSOCK_PORT_FORWARD,
+53 -15
View File
@@ -20,6 +20,29 @@ use vm_proto::{
WatchRequest, WriteFileRequest, WriteFileResponse, VSOCK_PORT, VSOCK_PORT_FORWARD,
};
/// What the kernel is told at boot, and the only place it is decided.
///
/// mitigations=off: the guest is disposable and holds no secrets (the proxy
/// substitutes secret placeholders host-side), so CPU vulnerability
/// mitigations inside it buy nothing and cost cycles. A console is only named
/// when something reads it — registering one costs the kernel a device probe
/// plus the log replay, and a plain exec run has nowhere to show it (dmesg
/// still works).
///
/// It is a function, not a literal inside the builder, because the command
/// line is part of what a guest IS: a measurement has to state the same string
/// the hypervisor is handed, and two spellings of it would be two guests.
pub fn command_line(verbose: bool, console: bool) -> String {
let base = "root=/dev/vda rw init=/usr/bin/vm-guest mitigations=off printk.time=1";
if verbose {
format!("console={} {}", CONSOLE_DEVICE, base)
} else if console {
format!("console={} {} quiet", CONSOLE_DEVICE, base)
} else {
format!("{} quiet", base)
}
}
// --- Mount types ---
#[derive(Debug, Clone)]
@@ -142,21 +165,7 @@ impl VmConfigBuilder {
boot_loader.set_initrd(initrd);
}
// mitigations=off: the guest is disposable and holds no secrets
// (the proxy substitutes secret placeholders host-side), so CPU
// vulnerability mitigations inside it buy nothing and cost cycles.
// A console is only named when something reads it — registering
// one costs the kernel a device probe plus the log replay, and a
// plain exec run has nowhere to show it (dmesg still works).
let base = "root=/dev/vda rw init=/usr/bin/vm-guest mitigations=off printk.time=1";
let cmdline = if self.verbose {
format!("console={} {}", CONSOLE_DEVICE, base)
} else if self.console {
format!("console={} {} quiet", CONSOLE_DEVICE, base)
} else {
format!("{} quiet", base)
};
boot_loader.set_command_line(&cmdline);
boot_loader.set_command_line(&command_line(self.verbose, self.console));
let memory_bytes = self.memory_mb * 1024 * 1024;
let config = VirtualMachineConfiguration::new(&boot_loader, self.cpus, memory_bytes);
@@ -368,6 +377,35 @@ impl Sandbox {
Ok(exit_code)
}
/// Ask the guest's platform for a report over `bind`.
///
/// The 64 bytes are what a verifier will check the report's caller field
/// against — [`Measurement::bind`](vm_measure::Measurement::bind). A guest
/// on ordinary hardware answers `none`, which is an answer: nothing here
/// will sign for a measurement.
///
/// The read is bounded because a guest that predates this request drops
/// the frame in silence, and a caller must not wait out a vm's whole life
/// on a question it was never able to hear.
pub fn attest(&self, bind: &[u8; 64]) -> Result<vm_measure::attest::Status> {
let stream = self.connect_vsock()?;
let mut writer = stream.try_clone()?;
let mut reader = stream;
reader.set_read_timeout(Some(Duration::from_secs(5)))?;
self.send_mount_requests(&mut writer, &mut reader)?;
frame::write_frame(&mut writer, frame::ATTEST_REQ, bind)?;
match frame::read_frame(&mut reader).context("reading attest response")? {
Some((frame::ATTEST_RESP, payload)) => {
serde_json::from_slice(&payload).context("reading the guest's attestation status")
}
Some((frame::ERROR, payload)) => bail!("{}", String::from_utf8_lossy(&payload)),
Some((other, _)) => bail!("unexpected frame type 0x{other:02x} in attest response"),
None => bail!("guest closed connection during attest"),
}
}
pub fn read_file(&self, path: &str) -> Result<Vec<u8>> {
let stream = self.connect_vsock()?;
let mut writer = stream.try_clone()?;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hanzo/vm",
"version": "2.0.0",
"version": "2.0.1",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",