mirror of
https://github.com/hanzoai/vm.git
synced 2026-10-01 00:47:56 +00:00
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:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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"
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@hanzo/vm",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.1",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
Reference in New Issue
Block a user