From 9167e6eca1f3586318aa7abdbcda73fab3afa50c Mon Sep 17 00:00:00 2001 From: HaRoLd <303926+HarryR@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:46:26 +0000 Subject: [PATCH 1/3] stage2: rework /data persistence (XTS, meta_bg) + mkruntime tooling /data: replace the planned authenticated dm-integrity + dm-crypt AEAD stack with confidentiality-only dm-crypt aes-xts-plain64 (AES-256-XTS). Authenticated encryption has no internal hash, so a never-written sector EIOs on read; ext4 online-resize does read-modify-write of new-group metadata (backup GDT via ext4_setup_new_descs) in the region it grows into, so the resize aborts unless the whole grow region is pre-wiped first -- O(volume), unacceptable on large volumes. XTS decrypts unwritten sectors to garbage instead of erroring, so the resize needs no wipe (the standard LUKS/dm-crypt pattern) and cold-boot cost is independent of volume size. Trade: at-rest tampering garbles plaintext rather than being cryptographically detected -- media integrity is left to the storage layer (EBS et al.), and a raw-disk-write adversary is out of scope. Key drift still fails closed (superblock decrypts to garbage -> no ext4 magic -> reinit). 64-byte TPM-derived key, new key label. loader: as PID 1, own fatal boot errors instead of returning. A returning init panics the kernel ("Attempted to kill init") and spins a vCPU at 100% forever (in prod and the QEMU harness). Now: log, 60s grace for console capture, then a clean ACPI power-off. ext4 /data base: meta_bg (drop resize_inode) for unbounded online growth; no resize= ceiling or journal bump needed. Verified growing to ~12 GiB across a meta_bg boundary with no wipe. mkruntime.sh: env knobs (SOURCE_DATE_EPOCH, DATA_SIZE_MB, ROOTFS_UUID, VERITY_SALT) with reproducible defaults; rootfs input now accepts a tar stream on stdin, a tar file, a directory, or a single binary (docker export documented as an example, not a dependency); is a file path or - for stdout; and --bootstrap prepends the loader ELF to emit a ready-to-run packed stage2. The Makefile pack target uses --bootstrap and forwards the knobs. tests: pivot-init drops the tamper-detection check (property removed with the AEAD stack); add grow-probe (large-volume meta_bg growth + sparse-hole). Prune unused probe examples. make ci green; PIVOT_TEST=PASS and GROW_TEST=PASS. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 1 - Makefile | 12 +- README.md | 12 +- crates/blockdev/src/lib.rs | 166 ++++---------------- crates/bootstrap/examples/blockdev-probe.rs | 68 -------- crates/bootstrap/examples/derive-probe.rs | 63 -------- crates/bootstrap/examples/grow-probe.rs | 86 ++++++++++ crates/bootstrap/examples/pivot-init.rs | 136 +++++++++------- crates/bootstrap/examples/reboot-probe.rs | 146 ----------------- crates/bootstrap/src/main.rs | 36 ++++- crates/tpm-derive/Cargo.toml | 1 - crates/tpm-derive/src/lib.rs | 121 +++++--------- tools/mkruntime.sh | 144 ++++++++++++----- 13 files changed, 385 insertions(+), 607 deletions(-) delete mode 100644 crates/bootstrap/examples/blockdev-probe.rs delete mode 100644 crates/bootstrap/examples/derive-probe.rs create mode 100644 crates/bootstrap/examples/grow-probe.rs delete mode 100644 crates/bootstrap/examples/reboot-probe.rs diff --git a/Cargo.lock b/Cargo.lock index 555fa9c..91bea72 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -557,7 +557,6 @@ name = "tpm-derive" version = "0.1.0" dependencies = [ "anyhow", - "serde_json", "sha2", "vaportpm-attest", ] diff --git a/Makefile b/Makefile index 023f420..b3b6528 100644 --- a/Makefile +++ b/Makefile @@ -121,14 +121,14 @@ pack-%: docker-build-base docker-build-erofs rustup target add $*-unknown-linux-musl && \ $(CARGO) build --release --locked --target $*-unknown-linux-musl -p bootstrap && \ $(CARGO) build --release --locked --target $*-unknown-linux-musl -p bootstrap --example $(INIT_PAYLOAD)" - @mkdir -p build/$*/runtime build/$*/pack - $(DOCKER_RUN) $(DOCKER_SAMEUSER) $(EROFS_IMAGE) \ + @mkdir -p build/$*/pack + $(DOCKER_RUN) $(DOCKER_SAMEUSER) \ + -e SOURCE_DATE_EPOCH -e DATA_SIZE_MB -e ROOTFS_UUID -e VERITY_SALT \ + $(EROFS_IMAGE) \ sh tools/mkruntime.sh \ + --bootstrap "target/$*-unknown-linux-musl/release/bootstrap" \ "target/$*-unknown-linux-musl/release/examples/$(INIT_PAYLOAD)" \ - "build/$*/runtime" - @cp "target/$*-unknown-linux-musl/release/bootstrap" "build/$*/pack/stage2" - @cat "build/$*/runtime/payload.zip" >> "build/$*/pack/stage2" - $(DOCKER_RUN) $(DOCKER_SAMEUSER) $(EROFS_IMAGE) sh -c "cd build/$*/pack && zip -q -A stage2" + "build/$*/pack/stage2" @echo "packed stage2 ($(INIT_PAYLOAD)): $$(stat -c%s build/$*/pack/stage2) bytes" clean: diff --git a/README.md b/README.md index 04199aa..069956a 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,8 @@ docker export → mkfs.erofs → veritysetup → zip → append to the loader EL 5. **Root** — assemble an **overlay**: the verity'd erofs as the immutable lower, a **tmpfs** upper. The merged `/` is therefore *pure verity'd erofs at every boot* — runtime writes hit RAM and can never persist to shadow the measured code. -6. **Data (p3)** — bring up `/data`: **authenticated encryption** via `dm-integrity` + `dm-crypt` - `aes-gcm-random` (AEAD), grown to fill the disk. Confidential *and* tamper-evident. +6. **Data (p3)** — bring up `/data`: **dm-crypt** `aes-xts-plain64` (AES-256-XTS), grown online to + fill the disk. Confidential; the TPM-derived key binds it to the boot chain. 7. **Attest** — copy stage1's attestation and drop stage2's alongside it in an ephemeral `/run/lockboot/`. 8. **`switch_root`** onto the overlay and **`exec /init`** — the image's own entrypoint, handed the @@ -48,8 +48,8 @@ docker export → mkfs.erofs → veritysetup → zip → append to the loader EL |---|---|---| | **Code / rootfs** | erofs + dm-verity, measured into PCR 14, under an **ephemeral** overlay | Immutable, integrity-verified, re-attestable each boot — no persistent code modification | | **Config** | verbatim stdin JSON → hashed → PCR 15 | Disk key bound to config; any change ⇒ fresh disk (fail-closed) | -| **Data** | `/data`: dm-integrity + dm-crypt `aes-gcm-random` (AEAD) | Confidential **and authenticated** — tampering is detected, not silently served | -| **Keys** | TPM-derived under a `PolicyPCR` session over a curated PCR set | No LUKS/header; drift in bound PCRs (code/config/firmware) ⇒ re-initialized | +| **Data** | `/data`: dm-crypt `aes-xts-plain64` (AES-256-XTS) | Confidential. Confidentiality-only: at-rest tampering garbles plaintext rather than being cryptographically detected — media integrity is left to the storage layer (EBS et al.), and a raw-disk-write adversary is out of scope | +| **Keys** | TPM-derived under a `PolicyPCR` session over a curated PCR set | No LUKS/header; drift in bound PCRs (code/config/firmware) ⇒ superblock decrypts to garbage ⇒ re-initialized (fail-closed) | See [`PCR-BINDING.md`](PCR-BINDING.md) for the bound PCR set, the {5,10} exclusions, and the IMA analysis. @@ -74,8 +74,8 @@ byte-deterministic. `unzip -l build//pack/stage2` inspects a packed payloa - **`bootstrap`** — the loader: read the appended payload, bring up persistence, pivot onto the overlay root, `exec /init`. - **`payload`** — the minimal appended-zip reader (self-extracting; reads `/proc/self/exe`). -- **`blockdev`** — raw device-mapper ioctls (no `libdevmapper`): dm-verity, dm-integrity + dm-crypt - AEAD, byte-stable GPT grow/discovery, ext4 online grow. +- **`blockdev`** — raw device-mapper ioctls (no `libdevmapper`): dm-verity, dm-crypt (XTS), + byte-stable GPT grow/discovery, ext4 online grow. - **`tpm-derive`** — deterministic PCR-bound key derivation (pure-Rust TPM2). ## License diff --git a/crates/blockdev/src/lib.rs b/crates/blockdev/src/lib.rs index 61c0dfa..1642875 100644 --- a/crates/blockdev/src/lib.rs +++ b/crates/blockdev/src/lib.rs @@ -122,25 +122,6 @@ fn ptype(guid: &str, label: &str) -> Type { } } -/// Map `device` as an `aes-xts-plain64` dm-crypt target named `name`, using the raw -/// 64-byte (512-bit) `key`, over the whole device. Returns the `/dev/mapper/` -/// node. No on-disk header: the key is TPM-derived and the ext4 superblock is the -/// initialized/not signal (see [`crypt_mount_or_mkfs`]). -pub fn crypt_open(name: &str, device: &Path, key: &[u8; 64]) -> Result { - let sectors = block_device_sectors(device)?; - let params = format!( - "aes-xts-plain64 {} 0 {} 0", - hex(key), - device.to_str().context("device path not UTF-8")? - ); - dm::create_and_load(name, "crypt", ¶ms, sectors, false) -} - -/// Tear down a dm-crypt (or any dm) mapping. -pub fn crypt_close(name: &str) -> Result<()> { - dm::remove(name) -} - /// Size of a block device in 512-byte sectors (via `BLKGETSIZE64`). fn block_device_sectors(device: &Path) -> Result { use std::os::fd::AsRawFd; @@ -412,57 +393,40 @@ fn flush_image(dev: &Path, blob: &[u8]) -> Result<()> { Ok(()) } -// ---- Data partition (p3): dm-integrity + dm-crypt (aes-gcm-random AEAD) + ext4 ---- +// ---- Data partition (p3): dm-crypt (aes-xts-plain64) + ext4 ---- -const CRYPT_NAME: &str = "data"; // /dev/mapper/data (the AEAD device ext4 lives on) -const INTEGRITY_NAME: &str = "data_int"; // /dev/mapper/data_int (tag store beneath crypt) +const CRYPT_NAME: &str = "data"; // /dev/mapper/data (the encrypted device ext4 lives on) const EXT4_BLOCK: u64 = 4096; /// ext4 superblock: little-endian magic 0xEF53 at byte offset 0x438. const EXT4_MAGIC: u16 = 0xEF53; const EXT4_MAGIC_OFFSET: u64 = 0x438; /// `EXT4_IOC_RESIZE_FS` = `_IOW('f', 16, __u64)` — in-kernel online grow (no `resize2fs` ships). const EXT4_IOC_RESIZE_FS: libc::c_ulong = 0x4008_6610; -/// dm-integrity superblock: 8-byte magic `"integrt\0"` at offset 0; `provided_data_sectors` -/// (`__le64`) at offset 16 (the kernel writes it during format; it is the usable size). -const SB_MAGIC: &[u8; 8] = b"integrt\0"; -const SB_PDS_OFFSET: u64 = 16; -/// dm-integrity target line tail (after the device): reserved-offset 0, 16-byte AEAD tag, journal -/// mode, one opt = 4096 block size. **No `internal_hash`** — the tag is supplied by the dm-crypt -/// AEAD above (via the bio integrity payload). -const INTEGRITY_OPTS: &str = "0 16 J 1 block_size:4096"; -const WIPE_CHUNK: usize = 4 << 20; // 4 MiB - -/// Bring up the authenticated-encrypted data partition (dm-integrity + dm-crypt `aes-gcm-random`) -/// at `mountpoint` and mount its ext4, grown to fill. **Cold** (fresh device, or the ext4 -/// superblock fails to read back through the AEAD because the key drifted → GCM auth EIO): format -/// dm-integrity (the kernel does it), **wipe the whole crypt device to initialize every AEAD tag**, -/// then flush the baked ext4 base. **Warm:** activate, mount, grow. Returns whether it -/// reinitialized (fail-closed on any drift/corruption). + +/// Bring up the encrypted data partition (dm-crypt `aes-xts-plain64`, confidentiality-only) at +/// `mountpoint` and mount its ext4, grown to fill p3. **Cold** (fresh device, or the key drifted so +/// the ext4 superblock decrypts to garbage): flush the baked ext4 base, then mount. **Warm:** the +/// superblock decrypts cleanly → mount + grow. Returns whether it reinitialized. /// -/// The wipe is mandatory: with no internal hash there is no background tag recalculation, so a -/// never-written block carries a garbage tag and would EIO on read — every sector must be written -/// once so it carries a valid tag (a one-time cold-boot cost; warm boots skip it). +/// XTS is confidentiality-only: there is no per-sector authentication tag, so a never-written sector +/// decrypts to (deterministic) garbage rather than erroring — which is why online resize needs **no +/// wipe** (ext4's read-modify-write of new-group metadata reads that garbage and overwrites it, as on +/// any plain dm-crypt volume; cold-boot cost is independent of volume size). Tamper at rest is +/// *garbled*, not *detected*: the storage layer (EBS et al.) provides media integrity beneath us, and +/// an adversary with raw-disk write access is out of scope. Key drift still fails closed: bad +/// plaintext ⇒ no ext4 magic ⇒ reinit. pub fn crypt_mount_or_mkfs( part: &Path, - key: &[u8; 32], + key: &[u8; 64], base_image: &[u8], mountpoint: &str, ) -> Result { - // dm-integrity: on a fresh device the kernel auto-formats; else adopt the on-disk geometry. - let formatted = integrity_formatted(part)?; - let pds = if formatted { - read_provided_data_sectors(part)? - } else { - format_integrity(part)? - }; - let int_dev = integrity_open(part, pds)?; - let mapper = crypt_aead_open(&int_dev, key, pds)?; + let mapper = crypt_xts_open(part, key)?; - // Cold if never formatted, or formatted but the ext4 superblock doesn't verify through the AEAD. - let reinit = !formatted || !probe_ext4(&mapper)?; + // Cold if the ext4 superblock doesn't decrypt to a valid magic (fresh device or drifted key). + let reinit = !probe_ext4(&mapper)?; if reinit { - wipe_device(&mapper, pds)?; // initialize every AEAD tag before any read - flush_image(&mapper, base_image)?; // lay down the baked empty ext4 base + flush_image(&mapper, base_image)?; // lay down the baked ext4 base } // errors=remount-ro: contain damage; the next boot re-derives and decides mount-vs-reinit. mount_fs(&mapper, mountpoint, "ext4", 0, Some("errors=remount-ro"))?; @@ -470,96 +434,26 @@ pub fn crypt_mount_or_mkfs( Ok(reinit) } -/// Does p3 already carry a dm-integrity superblock (magic at offset 0)? -fn integrity_formatted(part: &Path) -> Result { - let mut f = File::open(part).with_context(|| format!("open {}", part.display()))?; - let mut magic = [0u8; 8]; - match f.read_exact(&mut magic) { - Ok(()) => Ok(&magic == SB_MAGIC), - Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => Ok(false), - Err(e) => Err(e).with_context(|| format!("read integrity magic on {}", part.display())), - } -} - -/// Zero the first 4 KiB (arms the kernel auto-format), create a length-8 probe integrity target so -/// the kernel formats the device + writes `provided_data_sectors`, tear it down (flushing the -/// superblock), and read the size back. -fn format_integrity(part: &Path) -> Result { - zero_prefix(part, 4096)?; - let params = format!("{} {INTEGRITY_OPTS}", dev_str(part)?); - dm::create_and_load(INTEGRITY_NAME, "integrity", ¶ms, 8, false) - .context("probe-format dm-integrity")?; - dm::remove(INTEGRITY_NAME)?; // flush the freshly-written superblock to p3 - read_provided_data_sectors(part) -} - -/// Read `provided_data_sectors` (`__le64` @ 16) from p3's dm-integrity superblock. -fn read_provided_data_sectors(part: &Path) -> Result { - let mut f = File::open(part).with_context(|| format!("open {}", part.display()))?; - f.seek(SeekFrom::Start(SB_PDS_OFFSET))?; - let mut b = [0u8; 8]; - f.read_exact(&mut b).context("read provided_data_sectors")?; - let pds = u64::from_le_bytes(b); - if pds == 0 { - bail!("dm-integrity provided_data_sectors is 0 (format failed?)"); - } - Ok(pds) -} - -/// Activate the dm-integrity target over p3 at its provided-data size. -fn integrity_open(part: &Path, pds: u64) -> Result { - let params = format!("{} {INTEGRITY_OPTS}", dev_str(part)?); - dm::create_and_load(INTEGRITY_NAME, "integrity", ¶ms, pds, false) -} - -/// Stack dm-crypt `aes-gcm-random` (AEAD) over the integrity device; the 16-byte GCM tag per -/// sector is passed down to dm-integrity via the bio integrity payload. 32-byte (aes-256) key. -fn crypt_aead_open(int_dev: &Path, key: &[u8; 32], pds: u64) -> Result { +/// Activate dm-crypt `aes-xts-plain64` over the whole of p3. A 64-byte key = AES-256-XTS (two 256-bit +/// subkeys); 4096-byte crypto sectors align with ext4's block size. +fn crypt_xts_open(part: &Path, key: &[u8; 64]) -> Result { + // With sector_size:4096 the mapped length (in 512-byte sectors) must be a multiple of 8; p3 is + // not necessarily 4 KiB-aligned, so round down (drops at most 7 tail sectors ~= 3.5 KiB). + let sectors = block_device_sectors(part)? & !7; let params = format!( - "capi:gcm(aes)-random {} 0 {} 0 3 integrity:16:aead sector_size:4096 iv_large_sectors", + "aes-xts-plain64 {} 0 {} 0 1 sector_size:4096", hex(key), - dev_str(int_dev)? + dev_str(part)? ); - dm::create_and_load(CRYPT_NAME, "crypt", ¶ms, pds, false) -} - -/// Write `sectors` * 512 zero bytes across `dev` (+ fsync) so every AEAD tag is initialized. -fn wipe_device(dev: &Path, sectors: u64) -> Result<()> { - let mut f = OpenOptions::new() - .write(true) - .open(dev) - .with_context(|| format!("open {} to wipe", dev.display()))?; - let zeros = vec![0u8; WIPE_CHUNK]; - let total = sectors * 512; - let mut done = 0u64; - while done < total { - let n = (total - done).min(WIPE_CHUNK as u64) as usize; - f.write_all(&zeros[..n]) - .with_context(|| format!("wipe {}", dev.display()))?; - done += n as u64; - } - f.sync_all() - .with_context(|| format!("flush wipe {}", dev.display()))?; - Ok(()) -} - -/// Zero the first `len` bytes of `dev` (+ fsync). -fn zero_prefix(dev: &Path, len: usize) -> Result<()> { - let mut f = OpenOptions::new() - .write(true) - .open(dev) - .with_context(|| format!("open {} to zero prefix", dev.display()))?; - f.write_all(&vec![0u8; len]).context("zero prefix")?; - f.sync_all().context("flush zero prefix")?; - Ok(()) + dm::create_and_load(CRYPT_NAME, "crypt", ¶ms, sectors, false) } fn dev_str(dev: &Path) -> Result<&str> { dev.to_str().context("device path not UTF-8") } -/// Is there an ext4 superblock readable *through* `dev` (magic 0xEF53 at 0x438)? A GCM auth failure -/// (drifted key) surfaces as EIO, which we treat as "not valid" ⇒ reinit. +/// Is there an ext4 superblock readable *through* `dev` (magic 0xEF53 at 0x438)? Under XTS a drifted +/// key decrypts the superblock to garbage, so the magic simply won't match ⇒ reinit (fail closed). fn probe_ext4(dev: &Path) -> Result { let mut f = File::open(dev).with_context(|| format!("open {}", dev.display()))?; f.seek(SeekFrom::Start(EXT4_MAGIC_OFFSET))?; diff --git a/crates/bootstrap/examples/blockdev-probe.rs b/crates/bootstrap/examples/blockdev-probe.rs deleted file mode 100644 index 3b594ba..0000000 --- a/crates/bootstrap/examples/blockdev-probe.rs +++ /dev/null @@ -1,68 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -//! Boot-through-harness probe for `blockdev` against a real (grown) NVMe boot disk: -//! rebuild the GPT to add p2 (runtime) + p3 (data) after the ESP, get the kernel to -//! re-read the table, dm-crypt p3 with the TPM-derived key, and round-trip a sentinel — -//! confirming the plaintext survives the crypt mapping and is *ciphertext* on p3. Then -//! powers off. Not production — a test payload run as stage2 under the QEMU harness. - -use std::fs::OpenOptions; -use std::io::{Read, Seek, SeekFrom, Write}; - -const RUNTIME_LBAS: u64 = 65536; // 32 MiB reserved runtime partition -const OFFSET: u64 = 1 << 20; // 1 MiB into the crypt device -const SENTINEL: &[u8] = b"LOCKBOOT-BLOCKDEV-PROBE-v1-persist"; - -fn main() { - println!("=== blockdev-probe ==="); - if let Err(e) = run() { - println!("blockdev-probe error: {e:?}"); - } - println!("=== blockdev-probe done, powering off ==="); - poweroff(); -} - -fn run() -> anyhow::Result<()> { - let key = tpm_derive::derive_disk_key()?; // PCR-bound; workload binding tested elsewhere - - let device = blockdev::find_boot_device()?; - println!("boot_device={}", device.display()); - let (runtime, data) = blockdev::ensure_partitions(&device, RUNTIME_LBAS)?; - println!("runtime={} data={}", runtime.display(), data.display()); - - let mapper = blockdev::crypt_open("data", &data, &key)?; - println!("mapper={}", mapper.display()); - - // Write a sentinel through the crypt device, flush, read it back. - { - let mut f = OpenOptions::new().read(true).write(true).open(&mapper)?; - f.seek(SeekFrom::Start(OFFSET))?; - f.write_all(SENTINEL)?; - f.sync_all()?; - let mut buf = vec![0u8; SENTINEL.len()]; - f.seek(SeekFrom::Start(OFFSET))?; - f.read_exact(&mut buf)?; - println!("sentinel_roundtrip_ok={}", buf == SENTINEL); - } - - // The same offset on the raw partition must NOT be the plaintext (i.e. it's encrypted). - { - let mut f = OpenOptions::new().read(true).open(&data)?; - f.seek(SeekFrom::Start(OFFSET))?; - let mut buf = vec![0u8; SENTINEL.len()]; - f.read_exact(&mut buf)?; - println!("encrypted_on_disk={}", buf != SENTINEL); - } - Ok(()) -} - -fn poweroff() { - if std::process::id() == 1 { - // SAFETY: PID 1 power-off; sync then request poweroff. - unsafe { - libc::sync(); - libc::reboot(libc::LINUX_REBOOT_CMD_POWER_OFF); - } - } - std::process::exit(0); -} diff --git a/crates/bootstrap/examples/derive-probe.rs b/crates/bootstrap/examples/derive-probe.rs deleted file mode 100644 index e11d77d..0000000 --- a/crates/bootstrap/examples/derive-probe.rs +++ /dev/null @@ -1,63 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -//! Boot-through-harness probe for `tpm-derive` against the real (swtpm) TPM. Proves: -//! (1) deriving is deterministic within a boot; (2) measuring a workload into PCR15 -//! *changes* the key (so the disk is bound to the specific container config). Then -//! powers off. Not production — a test payload run as stage2 under the QEMU harness. - -use std::io::Read; - -fn main() { - // Take the container config from stdin's user-data if present, else a fixed sample. - let mut ud = String::new(); - let _ = std::io::stdin().read_to_string(&mut ud); - let container = serde_json::from_str::(&ud) - .ok() - .and_then(|d| d.get("container").cloned()) - .unwrap_or_else(|| serde_json::json!({"image": "probe@sha256:sample", "env": {"A": "1"}})); - - println!("=== derive-probe ==="); - // Before measuring the workload: PCR15 = 0. - let k_unbound = tpm_derive::derive_disk_key(); - - // Measure the workload, then derive again: PCR15 = H(container config). - let digest = tpm_derive::workload_digest(&container); - if let Err(e) = tpm_derive::extend_workload_pcr(&digest) { - println!("extend PCR15 error: {e:?}"); - } - let k_bound = tpm_derive::derive_disk_key(); - let k_bound2 = tpm_derive::derive_disk_key(); - - match (k_unbound, k_bound, k_bound2) { - (Ok(u), Ok(b), Ok(b2)) => { - println!("workload_digest={}", hex(&digest)); - println!("key_unbound={}", hex(&u)); - println!("key_bound={}", hex(&b)); - println!("workload_binding_changes_key={}", u != b); - println!("deterministic={}", b == b2); - println!("nonzero={}", b.iter().any(|&x| x != 0)); - } - _ => println!("derive error"), - } - println!("=== derive-probe done, powering off ==="); - poweroff(); -} - -fn hex(bytes: &[u8]) -> String { - let mut s = String::with_capacity(bytes.len() * 2); - for b in bytes { - s.push_str(&format!("{b:02x}")); - } - s -} - -fn poweroff() { - if std::process::id() == 1 { - // SAFETY: PID 1 power-off; sync then request poweroff. - unsafe { - libc::sync(); - libc::reboot(libc::LINUX_REBOOT_CMD_POWER_OFF); - } - } - std::process::exit(0); -} diff --git a/crates/bootstrap/examples/grow-probe.rs b/crates/bootstrap/examples/grow-probe.rs new file mode 100644 index 0000000..8363faa --- /dev/null +++ b/crates/bootstrap/examples/grow-probe.rs @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Large-volume `/init`: validates **meta_bg unbounded online growth**. The loader grows `/data` to +//! fill a multi-GB p3; this asserts it grew past ~8 GiB -- one meta-block-group boundary -- so the +//! resize genuinely crossed into a second meta_bg (with its own descriptors), not just more groups +//! within the first. Also checks fresh read/write on the grown fs and that a **sparse hole reads +//! back zero with no block I/O** (ext4 zero-fills holes in software). Single boot. Not production. + +use anyhow::{bail, Context, Result}; +use std::fs; +use std::io::{Read, Seek, SeekFrom, Write}; + +const LARGE_MIB: u64 = 8192; // ~one meta-block-group (64bit / 4 KiB); crossing it exercises meta_bg +const PROBE: &str = "/data/probe"; +const SPARSE: &str = "/data/sparse"; + +fn main() { + println!("=== grow-probe (PID {}) ===", std::process::id()); + match run() { + Ok(()) => { + println!("GROW_TEST=PASS meta_bg grew past 8 GiB, read/write + sparse hole OK"); + poweroff(); + } + Err(e) => { + println!("GROW_TEST=FAIL {e:?}"); + poweroff(); + } + } +} + +fn run() -> Result<()> { + let mib = statvfs_total("/data")? / (1 << 20); + println!("[grow] /data usable = {mib} MiB"); + if mib <= LARGE_MIB { + bail!("/data ({mib} MiB) did not cross the ~{LARGE_MIB} MiB meta_bg boundary -- unbounded online growth not exercised"); + } + + // Read/write on the freshly grown fs: write an 8 MiB file, sync, drop caches, read back. + let data = vec![0x5Au8; 8 << 20]; + fs::write(PROBE, &data).context("write probe file")?; + + // A sparse file with data only near the very top of the volume: the hole occupies most of it. + { + let mut f = fs::File::create(SPARSE).context("create sparse")?; + f.seek(SeekFrom::Start((mib - 16) << 20))?; // 16 MiB below the top + f.write_all(b"end").context("write sparse tail")?; + } + unsafe { libc::sync() }; + drop_caches()?; + + if fs::read(PROBE).context("read probe back")? != data { + bail!("probe file readback mismatch"); + } + // Read a hole in the sparse file: ext4 zero-fills with no block I/O (nothing reaches the disk). + let mut hole = [0u8; 4096]; + let mut sf = fs::File::open(SPARSE)?; + sf.seek(SeekFrom::Start(1 << 20))?; // deep in the hole + sf.read_exact(&mut hole)?; + if hole.iter().any(|&b| b != 0) { + bail!("sparse hole did not read back as zero"); + } + println!("[grow] readback ok; sparse hole reads zero with no block I/O"); + Ok(()) +} + +fn drop_caches() -> Result<()> { + fs::write("/proc/sys/vm/drop_caches", b"3").context("drop_caches") +} + +fn statvfs_total(path: &str) -> Result { + let c = std::ffi::CString::new(path)?; + let mut st: libc::statvfs = unsafe { std::mem::zeroed() }; + if unsafe { libc::statvfs(c.as_ptr(), &mut st) } < 0 { + return Err(std::io::Error::last_os_error()).with_context(|| format!("statvfs {path}")); + } + Ok(st.f_blocks * st.f_frsize) +} + +fn poweroff() { + // SAFETY: PID 1 power-off; sync then poweroff. + unsafe { + libc::sync(); + libc::reboot(libc::LINUX_REBOOT_CMD_POWER_OFF); + } + std::process::exit(0); +} diff --git a/crates/bootstrap/examples/pivot-init.rs b/crates/bootstrap/examples/pivot-init.rs index a605da5..52e3613 100644 --- a/crates/bootstrap/examples/pivot-init.rs +++ b/crates/bootstrap/examples/pivot-init.rs @@ -1,14 +1,14 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 -//! Boot-through-harness `/init` for the loader: a minimal payload baked into the test -//! `rootfs.erofs`. It runs as PID 1 *after* the loader has pivoted onto the overlay root, and -//! verifies the whole contract across an in-guest reboot: -//! - the config JSON arrives on stdin (fd 0); -//! - the stage1 + stage2 attestations are present in `/run/lockboot/`; -//! - `/data` (encrypted) **persists** across the reboot; -//! - the overlay root is **ephemeral** — a file written to `/` on boot 1 is gone on boot 2 -//! (proving runtime writes cannot persist to shadow the verity'd code). -//! Boot counter + sentinel live on `/data`. Not production. +//! Boot-through-harness `/init` for the loader: a comprehensive property-checker baked into the +//! test `rootfs.erofs`. Runs as PID 1 *after* the loader has pivoted, and verifies the essential +//! properties across an in-guest reboot: +//! boot 1: [config] the JSON arrives on stdin; [attest] the attestation chain is present; +//! [resize] /data grew far past the tiny baked base; write a persist sentinel + a root +//! ephemeral probe; reboot. +//! boot 2: [persist] the /data sentinel survived; [integrity] the overlay-root probe did NOT +//! (ephemeral). Then PASS. +//! Not production. use anyhow::{bail, Context, Result}; use std::fs; @@ -19,16 +19,17 @@ const SENTINEL: &[u8] = b"LOCKBOOT-PIVOT-PERSIST-v1"; const EPHEMERAL_PROBE: &str = "/ephemeral-probe"; // written to the overlay root (tmpfs upper) const COUNTER: &str = "/data/boot-count"; const SENTINEL_FILE: &str = "/data/sentinel"; +const BASE_MIB: u64 = 64; // the baked ext4 base; a grown /data must be well past this fn main() { println!("=== pivot-init (PID {}) ===", std::process::id()); match run() { Ok(true) => { - println!("PIVOT_TEST=PASS /data persisted and overlay root was ephemeral"); + println!("PIVOT_TEST=PASS all properties verified"); poweroff(); } Ok(false) => { - println!("boot 1: contract up, wrote sentinel + ephemeral probe, rebooting"); + println!("boot 1: properties up, wrote sentinel + ephemeral probe, rebooting"); reboot(); } Err(e) => { @@ -39,67 +40,94 @@ fn main() { } fn run() -> Result { - // Config transparently forwarded on stdin. + let boot: u32 = fs::read_to_string(COUNTER) + .ok() + .and_then(|s| s.trim().parse().ok()) + .unwrap_or(0); + fs::write(COUNTER, (boot + 1).to_string()).context("write boot counter to /data")?; + println!("boot#{}", boot + 1); + + if boot == 0 { + check_config()?; + check_attestations()?; + check_resized()?; + fs::write(EPHEMERAL_PROBE, b"x").context("write ephemeral probe to /")?; + fs::write(SENTINEL_FILE, SENTINEL).context("write sentinel to /data")?; + unsafe { libc::sync() }; + return Ok(false); // reboot + } + + // boot 2: persistence + ephemerality. + let sentinel_ok = fs::read(SENTINEL_FILE) + .map(|b| b == SENTINEL) + .unwrap_or(false); + let ephemeral_gone = !Path::new(EPHEMERAL_PROBE).exists(); + println!("[persist] /data sentinel present = {sentinel_ok}"); + println!("[integrity] overlay-root ephemeral (probe gone) = {ephemeral_gone}"); + if !sentinel_ok { + bail!("/data sentinel missing after reboot -- persistence broke or key unstable"); + } + if !ephemeral_gone { + bail!("/ephemeral-probe survived the reboot -- overlay root is NOT ephemeral (integrity risk)"); + } + Ok(true) +} + +/// [config] the transparent JSON arrives verbatim on stdin. +fn check_config() -> Result<()> { let mut cfg = String::new(); std::io::stdin() .read_to_string(&mut cfg) .context("read config from stdin")?; if cfg.trim().is_empty() { - bail!("empty config on stdin — loader did not forward the doc"); + bail!("empty config on stdin -- loader did not forward the doc"); } println!( - "config_len={} has_container={}", + "[config] {} bytes on stdin, has_container={}", cfg.len(), cfg.contains("container") ); + Ok(()) +} - // Attestation chain in the ephemeral tmpfs. - for a in ["/run/lockboot/stage1.attest", "/run/lockboot/stage2.attest"] { - println!( - "attest {a}: {}", - if Path::new(a).exists() { - "present" - } else { - "MISSING" - } - ); +/// [attest] stage2 attestation is present; stage1's copy is best-effort (tmpfs->tmpfs). +fn check_attestations() -> Result<()> { + let s1 = Path::new("/run/lockboot/stage1.attest").exists(); + let s2 = Path::new("/run/lockboot/stage2.attest").exists(); + println!("[attest] stage1={} stage2={}", present(s1), present(s2)); + if !s2 { + bail!("stage2 attestation missing from /run/lockboot"); } - if !Path::new("/run/lockboot/stage2.attest").exists() { - bail!("stage2 attestation missing"); + Ok(()) +} + +/// [resize] /data grew online to fill p3 -- its usable size is far past the tiny baked base. +fn check_resized() -> Result<()> { + let mib = statvfs_total("/data")? / (1 << 20); + println!("[resize] /data usable = {mib} MiB (baked base was {BASE_MIB} MiB)"); + if mib < BASE_MIB * 2 { + bail!("/data did not grow past the base ({mib} MiB) -- online resize failed"); } + Ok(()) +} - let boot: u32 = fs::read_to_string(COUNTER) - .ok() - .and_then(|s| s.trim().parse().ok()) - .unwrap_or(0); - fs::write(COUNTER, (boot + 1).to_string()).context("write boot counter to /data")?; +// ---- helpers ---- - let ephemeral_present = Path::new(EPHEMERAL_PROBE).exists(); - let sentinel_present = fs::read(SENTINEL_FILE) - .map(|b| b == SENTINEL) - .unwrap_or(false); - println!( - "boot#{} ephemeral_probe_present={ephemeral_present} data_sentinel_present={sentinel_present}", - boot + 1 - ); - - if boot == 0 { - // Write to the root (tmpfs upper — must NOT survive) and to /data (encrypted — must survive). - fs::write(EPHEMERAL_PROBE, b"x").context("write ephemeral probe to /")?; - fs::write(SENTINEL_FILE, SENTINEL).context("write sentinel to /data")?; - unsafe { libc::sync() }; - return Ok(false); // reboot +fn statvfs_total(path: &str) -> Result { + let c = std::ffi::CString::new(path)?; + let mut st: libc::statvfs = unsafe { std::mem::zeroed() }; + if unsafe { libc::statvfs(c.as_ptr(), &mut st) } < 0 { + return Err(std::io::Error::last_os_error()).with_context(|| format!("statvfs {path}")); } + Ok(st.f_blocks * st.f_frsize) +} - if !sentinel_present { - bail!("/data sentinel missing after reboot — persistence broke or key unstable"); +fn present(b: bool) -> &'static str { + if b { + "present" + } else { + "MISSING" } - if ephemeral_present { - bail!( - "/ephemeral-probe survived the reboot — overlay root is NOT ephemeral (integrity risk)" - ); - } - Ok(true) } fn reboot() { diff --git a/crates/bootstrap/examples/reboot-probe.rs b/crates/bootstrap/examples/reboot-probe.rs deleted file mode 100644 index c94895e..0000000 --- a/crates/bootstrap/examples/reboot-probe.rs +++ /dev/null @@ -1,146 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 - -//! Boot-through-harness **reboot** test (Option 1: in-guest reboot within one QEMU -//! invocation — the same swtpm + disk span the reset, so the derived key and the data -//! both persist). Self-detecting and bounded: -//! boot 1 (counter 0): partition, dm-crypt, write a sentinel, `reboot()`; -//! boot 2 (counter 1): re-derive the key, dm-crypt, read the sentinel back — -//! PASS if it survived (proves stable key + persistence + that the -//! ESP survived the GPT rewrite so stage0 re-booted), else FAIL. -//! A plaintext boot counter on the (unencrypted) runtime partition bounds it to two -//! boots and distinguishes "key unstable" from a hang. Not production. - -use anyhow::Result; -use std::fs::OpenOptions; -use std::io::{Read, Seek, SeekFrom, Write}; -use std::path::Path; - -const RUNTIME_LBAS: u64 = 65536; // 32 MiB reserved runtime partition -const COUNTER_OFF: u64 = 0; // plaintext boot counter, start of the runtime partition -const SENTINEL_OFF: u64 = 1 << 20; // 1 MiB into the encrypted data device -const SENTINEL: &[u8] = b"LOCKBOOT-REBOOT-PROBE-sentinel-v1"; - -enum Outcome { - Pass(u32), - WroteRebooting, - Fail(u32), -} - -fn main() { - println!("=== reboot-probe ==="); - match run() { - Ok(Outcome::Pass(n)) => { - println!("REBOOT_TEST=PASS sentinel survived reboot (boot #{n})"); - } - Ok(Outcome::WroteRebooting) => { - println!("boot 1: wrote sentinel, rebooting"); - reboot_restart(); - return; // unreached - } - Ok(Outcome::Fail(n)) => { - println!("REBOOT_TEST=FAIL sentinel missing after reboot (boot #{n}) — key unstable?"); - } - Err(e) => println!("reboot-probe error: {e:?}"), - } - println!("=== reboot-probe done, powering off ==="); - poweroff(); -} - -fn run() -> Result { - let key = tpm_derive::derive_disk_key()?; - let device = blockdev::find_boot_device()?; - let (runtime, data) = blockdev::ensure_partitions(&device, RUNTIME_LBAS)?; - let mapper = blockdev::crypt_open("data", &data, &key)?; - - let boot = read_u32(&runtime, COUNTER_OFF)?; // 0 on the very first boot - write_u32(&runtime, COUNTER_OFF, boot + 1)?; // survives (runtime part is plaintext) - println!("boot_device={} boot#{}", device.display(), boot + 1); - // Diagnostics: is the key stable across the reboot? did the p3 ciphertext persist? - println!("key_fp={}", hex(&key[..8])); - println!( - "p3_raw@sentinel={}", - hex(&read_raw(&data, SENTINEL_OFF, 16)?) - ); - for (i, v) in tpm_derive::read_all_pcrs()? { - println!("pcr{i:02}={}", hex(&v[..8])); - } - - if sentinel_matches(&mapper)? { - return Ok(Outcome::Pass(boot + 1)); - } - if boot == 0 { - write_sentinel(&mapper)?; - return Ok(Outcome::WroteRebooting); - } - Ok(Outcome::Fail(boot + 1)) -} - -fn read_raw(dev: &Path, off: u64, len: usize) -> Result> { - let mut f = OpenOptions::new().read(true).open(dev)?; - f.seek(SeekFrom::Start(off))?; - let mut b = vec![0u8; len]; - f.read_exact(&mut b)?; - Ok(b) -} - -fn hex(bytes: &[u8]) -> String { - let mut s = String::with_capacity(bytes.len() * 2); - for b in bytes { - s.push_str(&format!("{b:02x}")); - } - s -} - -fn read_u32(dev: &Path, off: u64) -> Result { - let mut f = OpenOptions::new().read(true).open(dev)?; - f.seek(SeekFrom::Start(off))?; - let mut b = [0u8; 4]; - f.read_exact(&mut b)?; - Ok(u32::from_le_bytes(b)) -} - -fn write_u32(dev: &Path, off: u64, v: u32) -> Result<()> { - let mut f = OpenOptions::new().write(true).open(dev)?; - f.seek(SeekFrom::Start(off))?; - f.write_all(&v.to_le_bytes())?; - f.sync_all()?; - Ok(()) -} - -fn sentinel_matches(mapper: &Path) -> Result { - let mut f = OpenOptions::new().read(true).open(mapper)?; - f.seek(SeekFrom::Start(SENTINEL_OFF))?; - let mut b = vec![0u8; SENTINEL.len()]; - f.read_exact(&mut b)?; - Ok(b == SENTINEL) -} - -fn write_sentinel(mapper: &Path) -> Result<()> { - let mut f = OpenOptions::new().write(true).open(mapper)?; - f.seek(SeekFrom::Start(SENTINEL_OFF))?; - f.write_all(SENTINEL)?; - f.sync_all()?; - Ok(()) -} - -fn reboot_restart() { - if std::process::id() == 1 { - // SAFETY: PID 1 warm reboot; sync then request restart. - unsafe { - libc::sync(); - libc::reboot(libc::LINUX_REBOOT_CMD_RESTART); - } - } - std::process::exit(0); -} - -fn poweroff() { - if std::process::id() == 1 { - // SAFETY: PID 1 power-off; sync then request poweroff. - unsafe { - libc::sync(); - libc::reboot(libc::LINUX_REBOOT_CMD_POWER_OFF); - } - } - std::process::exit(0); -} diff --git a/crates/bootstrap/src/main.rs b/crates/bootstrap/src/main.rs index 9988b04..ad465a3 100644 --- a/crates/bootstrap/src/main.rs +++ b/crates/bootstrap/src/main.rs @@ -26,7 +26,17 @@ const ATTEST_DIR: &str = "/newroot/run/lockboot"; const INIT: &str = "/init"; const STAGE1_ATTEST: &str = "/tmp/stage1.attest"; -fn main() -> Result<()> { +fn main() { + // PID 1 must never exit: a returning init panics the kernel ("Attempted to kill init"), which on + // a cloud VM spins a vCPU at 100% forever. Own our failure instead -- log it, then power off. + if let Err(e) = run() { + fatal_halt(e); + } +} + +/// The loader body. Returns only on error; on success `switch_root_exec` execs `/init` and never +/// returns. +fn run() -> Result<()> { // Our appended payload (rootfs erofs + verity params + ext4 base), read from /proc/self/exe. let payload = payload::load_self().context("load appended payload")?; @@ -35,12 +45,12 @@ fn main() -> Result<()> { std::io::stdin() .read_to_end(&mut cfg) .context("read config from stdin")?; - tpm_derive::extend_workload_pcr(&tpm_derive::config_digest(&cfg)) + tpm_derive::extend_config_pcr(&tpm_derive::config_digest(&cfg)) .context("bind config into PCR15")?; // SAFETY: fd 0 is a seekable memfd (stage1's model); rewind it for the downstream /init. unsafe { libc::lseek(0, 0, libc::SEEK_SET) }; - // AEAD data key, bound to PCR14 (code+rootfs) + PCR15 (config) + the rest. + // XTS data key, bound to PCR14 (code+rootfs) + PCR15 (config) + the rest. let key = tpm_derive::derive_data_key().context("derive data key")?; // Partitions on the boot device. @@ -97,6 +107,26 @@ fn main() -> Result<()> { pivot::switch_root_exec(NEWROOT, INIT) } +/// Fatal boot error handler for PID 1. Log the error, give a 60 s grace window for console/serial +/// capture, then issue a clean ACPI power-off so a failed instance *stops* rather than spinning a +/// vCPU on a kernel panic (or looping via `panic=`-reboot). Never returns. In the QEMU harness the +/// power-off makes qemu exit, so a failed boot ends the test instead of hanging. +fn fatal_halt(err: anyhow::Error) -> ! { + const GRACE_SECS: libc::c_uint = 60; + eprintln!("loader: FATAL: {err:?}"); + eprintln!("loader: powering off in {GRACE_SECS}s"); + // SAFETY: PID 1 shutdown path -- flush, wait out the grace window, power off, never return. + unsafe { + libc::sync(); + libc::sleep(GRACE_SECS); + libc::reboot(libc::LINUX_REBOOT_CMD_POWER_OFF); + // If power-off is somehow refused, park forever rather than exit -> kernel panic. + loop { + libc::pause(); + } + } +} + /// Copy stage1's attestation over and drop our own alongside it. Best-effort: the loader must not /// fail to boot because attestation evidence couldn't be written. fn write_attestations(cfg: &[u8]) { diff --git a/crates/tpm-derive/Cargo.toml b/crates/tpm-derive/Cargo.toml index 9a8aee5..f5ea56f 100644 --- a/crates/tpm-derive/Cargo.toml +++ b/crates/tpm-derive/Cargo.toml @@ -6,7 +6,6 @@ license.workspace = true [dependencies] anyhow = { workspace = true } -serde_json = { workspace = true } sha2 = { workspace = true } # The TPM I/O + deterministic PCR-bound derivation live in vaportpm. Pinned to the # v0.3.0 release rev (matches the sibling repos' git-dep style). diff --git a/crates/tpm-derive/src/lib.rs b/crates/tpm-derive/src/lib.rs index 368aaf2..16b6743 100644 --- a/crates/tpm-derive/src/lib.rs +++ b/crates/tpm-derive/src/lib.rs @@ -1,13 +1,11 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 -//! Deterministic, PCR-bound disk-key derivation for `bootstrap`. +//! Deterministic, PCR-bound key derivation for the loader. //! -//! A thin wrapper over vaportpm's `DeriveOps` (where the TPM I/O + packet marshalling -//! already live, and where this logic folds in once the feature settles). No RNG, no -//! sealed blob, no on-disk header: the key is a deterministic function of the TPM's -//! stable hierarchy seed and the current PCR state. Any PCR drift yields a different -//! key, so the encrypted data partition fails to decrypt to a valid filesystem and is -//! re-initialized (fail closed). +//! A thin wrapper over vaportpm's `DeriveOps`. No RNG, no sealed blob, no on-disk header: the key +//! is a deterministic function of the TPM's stable hierarchy seed and the current PCR state. Any +//! bound-PCR drift (firmware/boot/policy/code/config) yields a different key, so `/data`'s ext4 +//! superblock decrypts to garbage and it is re-initialized (fail closed). use anyhow::{anyhow, Context, Result}; use sha2::{Digest, Sha256}; @@ -15,105 +13,56 @@ use vaportpm_attest::{DeriveOps, PcrOps, Tpm, TpmAlg}; // The TPM device path (/dev/tpmrm0) lives in vaportpm's `Tpm::open()`. -/// PCR the workload identity is measured into (stage2's PCR, per the stage1 contract: -/// stage1 leaves PCR15 for the payload). Included in [`DISK_KEY_PCRS`], so extending it -/// binds the workload into the disk key. -const WORKLOAD_PCR: u8 = 15; - -/// Canonical digest of the container workload: a SHA-256 over the **entire** `container` -/// config dict — image *and* all args/env/mounts/restart — so the disk key locks to -/// *this code in this exact configuration*, not merely to the image. Canonical form is -/// compact JSON with sorted object keys (serde_json's default `Map` is a `BTreeMap`), so -/// formatting differences don't change the identity. (RFC 8785 / JCS is the fully -/// interop-portable form if a remote verifier ever recomputes this — future hardening.) -pub fn workload_digest(container: &serde_json::Value) -> [u8; 32] { - let canonical = serde_json::to_vec(container).unwrap_or_default(); - Sha256::digest(canonical).into() -} +/// The PCR stage1 leaves for the payload (the stage1 contract). Included in [`DATA_KEY_PCRS`], so +/// extending it with the config digest binds the config into the data key. +const CONFIG_PCR: u8 = 15; /// SHA-256 over the **verbatim** stdin config doc. The loader is transparent — it forwards the -/// exact bytes on to `/init` — so binding the raw bytes into PCR15 (via [`extend_workload_pcr`]) +/// exact bytes on to `/init` — so binding the raw bytes into PCR15 (via [`extend_config_pcr`]) /// locks the disk key to *this config*. Byte-stability across boots is required (stage1 delivers /// the same user-data memfd every boot). pub fn config_digest(doc: &[u8]) -> [u8; 32] { Sha256::digest(doc).into() } -/// Extend PCR15 with the workload digest. **Must run before [`derive_disk_key`]** so the -/// derived key is bound to this workload — a different config yields a different PCR15, -/// hence a different key, hence a fresh (empty) disk. Also surfaces the workload in any -/// attestation quote. -pub fn extend_workload_pcr(digest: &[u8; 32]) -> Result<()> { +/// Extend PCR15 with the config digest. **Must run before [`derive_data_key`]** so the derived key +/// is bound to this config — a different config yields a different PCR15, hence a different key, +/// hence a fresh (empty) `/data`. Also surfaces the config in any attestation quote. +pub fn extend_config_pcr(digest: &[u8; 32]) -> Result<()> { let mut tpm = Tpm::open().context("open TPM resource manager")?; - tpm.pcr_extend_bank(WORKLOAD_PCR, TpmAlg::Sha256, digest) - .context("extend PCR15 with workload digest") + tpm.pcr_extend_bank(CONFIG_PCR, TpmAlg::Sha256, digest) + .context("extend PCR15 with config digest") } -/// Namespace for the encrypted data partition's key — separates it from any other -/// PCR-bound derivation on the same TPM. -const DISK_KEY_LABEL: &[u8] = b"lockboot.containers.diskkey.v1"; - -/// PCRs the disk key is bound to: **every PCR except PCR5 and PCR10** — bind broadly by -/// default (per-instance keys make it free; values differ per instance/vendor), so any -/// real change to firmware/boot/policy/payload wipes. The two exclusions are settled -/// (2026-07-11), each measured elsewhere or otherwise not additive for us — see -/// PCR-BINDING.md for the full analysis: +/// PCRs the data key is bound to: **every PCR except PCR5 and PCR10** — bind broadly by default +/// (per-instance keys make it free; values differ per instance/vendor), so any real change to +/// firmware/boot/policy/payload wipes. The two exclusions are settled, each measured elsewhere or +/// otherwise not additive for us — see PCR-BINDING.md for the full analysis: /// -/// - **PCR5 (GPT):** `bootstrap` rewrites the GPT at runtime (adds p2/p3), so it changes -/// across the first reboot. (On Nitro its profile is even broader than the GPT.) -/// - **PCR10 (IMA):** redundant, not a loss. It carries the IMA *boot aggregate* = -/// hash(PCR0-9) — inputs we already bind individually (0-4,6,7,8,9), minus the -/// deliberately-excluded PCR5 — plus module measurements, which are already covered by -/// the UKI measurement in **PCR14** (all modules ship in the measured initramfs). We -/// never use IMA appraisal (`security.ima` xattrs). And `CONFIG_IMA=y` in the prebuilt -/// Fedora kernel extends PCR10 with the boot aggregate unconditionally, so it couldn't -/// be suppressed short of recompiling the kernel anyway. -const DISK_KEY_PCRS: &[u8] = &[ +/// - **PCR5 (GPT):** the loader rewrites the GPT at runtime (adds p2/p3), so it changes across the +/// first reboot. (On Nitro its profile is even broader than the GPT.) +/// - **PCR10 (IMA):** redundant, not a loss. It carries the IMA *boot aggregate* = hash(PCR0-9) — +/// inputs we already bind individually (0-4,6,7,8,9), minus the deliberately-excluded PCR5 — +/// plus module measurements, which are already covered by the UKI measurement in **PCR14** (all +/// modules ship in the measured initramfs). We never use IMA appraisal (`security.ima` xattrs). +/// And `CONFIG_IMA=y` in the prebuilt Fedora kernel extends PCR10 with the boot aggregate +/// unconditionally, so it couldn't be suppressed short of recompiling the kernel anyway. +const DATA_KEY_PCRS: &[u8] = &[ 0, 1, 2, 3, 4, /* 5 excluded (GPT) */ 6, 7, 8, 9, /* 10 excluded (IMA) */ 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, ]; -/// Diagnostic: read all 24 PCRs (SHA-256 bank) for cross-boot comparison. -pub fn read_all_pcrs() -> Result> { - let mut tpm = Tpm::open().context("open TPM")?; - let mut out = Vec::new(); - for i in 0u8..24 { - let r = tpm.pcr_read_bank(&[i], TpmAlg::Sha256)?; - let mut v = [0u8; 32]; - if let Some((_, d)) = r.iter().find(|(idx, _)| *idx == i) { - if d.len() == 32 { - v.copy_from_slice(d); - } - } - out.push((i, v)); - } - Ok(out) -} +/// Namespace for the `/data` encryption key on this TPM. +const DATA_KEY_LABEL: &[u8] = b"lockboot.stage2.datakey.xts.v1"; -/// Derive the 64-byte dm-crypt key (aes-xts-plain64), bound to the boot-chain PCRs and -/// this TPM. (Used by the plain-dm-crypt probes.) -pub fn derive_disk_key() -> Result<[u8; 64]> { +/// Derive the 64-byte `aes-xts-plain64` key (AES-256-XTS = two 256-bit subkeys) for the encrypted +/// `/data` partition (dm-crypt), bound to the PCR set + this TPM's stable hierarchy seed. +pub fn derive_data_key() -> Result<[u8; 64]> { let mut tpm = Tpm::open().context("open TPM resource manager")?; let bytes = tpm - .derive_pcr_bound(DISK_KEY_PCRS, DISK_KEY_LABEL, 64) - .context("derive disk key from TPM")?; + .derive_pcr_bound(DATA_KEY_PCRS, DATA_KEY_LABEL, 64) + .context("derive data key from TPM")?; bytes .try_into() .map_err(|_| anyhow!("expected 64 derived bytes")) } - -/// Namespace for the authenticated `/data` key — separate label from the plain disk key so the -/// two derivations never alias. -const DATA_KEY_LABEL: &[u8] = b"lockboot.containers.datakey.aead.v1"; - -/// Derive the 32-byte `aes-gcm-random` AEAD key for the authenticated `/data` partition -/// (dm-integrity + dm-crypt), bound to the same PCR set. -pub fn derive_data_key() -> Result<[u8; 32]> { - let mut tpm = Tpm::open().context("open TPM resource manager")?; - let bytes = tpm - .derive_pcr_bound(DISK_KEY_PCRS, DATA_KEY_LABEL, 32) - .context("derive AEAD data key from TPM")?; - bytes - .try_into() - .map_err(|_| anyhow!("expected 32 derived bytes")) -} diff --git a/tools/mkruntime.sh b/tools/mkruntime.sh index 4b0b51c..c011f65 100644 --- a/tools/mkruntime.sh +++ b/tools/mkruntime.sh @@ -1,43 +1,89 @@ #!/bin/sh -# Forge the deterministic payload INSIDE lockboot:erofs-builder (build-time only; none of these -# tools ship to the instance). Emits a single self-describing ZIP at $2/payload.zip, which -# `make pack` appends to the loader ELF. Entries: +# Forge the deterministic stage2 payload INSIDE lockboot:erofs-builder (build-time only; none of +# these tools ship to the instance). The payload is a single self-describing ZIP with entries: # rootfs.erofs erofs image ++ dm-verity hash tree, data + hash on ONE device with # --no-superblock so the tree starts exactly at the data end -> the runtime # dm-verity table's hash_start_block == DATA_BLOCKS (see blockdev::verity_open). -# data-base.img empty journaled ext4 base for p3 (flushed on reinit, grown online via ioctl). +# data-base.img empty journaled ext4 base for p3 (flushed on reinit, grown online via ioctl to +# fill the disk; meta_bg => unbounded growth). # verity.json { root_hash, salt, data_blocks } for the loader (payload::Verity). +# With --bootstrap, the loader ELF is prepended and the zip self-adjusted, so the output is a +# ready-to-run packed stage2 (what `make pack` produces) instead of a bare zip. # -# Reproducible: fixed UUID + salt, SOURCE_DATE_EPOCH, no lazy init, fixed mtimes + `zip -X`. -# Byte-verified CI==local. +# Reproducible: with default knobs the output is byte-identical across builds (fixed UUID + salt, +# SOURCE_DATE_EPOCH, no lazy init, fixed mtimes + `zip -X`). Byte-verified CI==local. set -eu -SRC_ROOTFS="${1:?usage: mkruntime.sh }" -OUT="${2:?usage: mkruntime.sh }" -export SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-0}" +usage() { + cat <<'EOF' +mkruntime.sh -- forge the deterministic stage2 payload (runs in lockboot:erofs-builder). -# Fixed so the payload (hence the packed stage2, hence PCR14) is deterministic across builds. -FIXED_UUID="4c4f434b-0000-0000-0000-000000000002" -FIXED_SALT="6c6f636b626f6f742d72756e74696d652d766572697479732d73616c742d3031" -# Base ext4 size. Small: grown online to fill p3 at runtime (EXT4_IOC_RESIZE_FS), and the smaller -# the base the less we write on first boot / reinit. resize_inode reserves ~1024x growth headroom -# (64 MiB -> ~64 GiB); larger volumes need meta_bg (a documented follow-up). -DATA_EXT4_SIZE_MB=64 +Usage: mkruntime.sh [--bootstrap ] + + --bootstrap prepend this loader ELF and self-adjust the zip, so is a + ready-to-run packed stage2 (ELF + payload) instead of a bare zip. + one of: + - read a tar stream on stdin (a container rootfs) + a tar archive of a container rootfs + a directory, used as the rootfs verbatim + a single executable, installed as /init + output file path, or `-` to write to stdout + +Environment knobs (optional; the defaults keep the build reproducible / PCR14-stable): + SOURCE_DATE_EPOCH epoch for mtimes + erofs timestamp (default 0) + DATA_SIZE_MB ext4 /data base size, MiB (default 64) + ROOTFS_UUID erofs + ext4 UUID (default lockboot) + VERITY_SALT dm-verity salt, hex (default lockboot) + +Examples: + # bare payload.zip from a single static PID-1 binary: + mkruntime.sh ./myinit payload.zip + + # a ready-to-run stage2 from a Docker image (no docker dependency baked in here): + docker export "$(docker create --rm myimage)" | \ + mkruntime.sh --bootstrap ./bootstrap - stage2 +EOF +} + +BOOTSTRAP="" +while [ $# -gt 0 ]; do + case "$1" in + --bootstrap) BOOTSTRAP="${2:?--bootstrap needs an path}"; shift 2 ;; + --bootstrap=*) BOOTSTRAP="${1#*=}"; shift ;; + -h|--help) usage; exit 0 ;; + --) shift; break ;; + --*) echo "unknown option: $1" >&2; usage >&2; exit 2 ;; + *) break ;; # positional (rootfs); includes "-" for stdin + esac +done + +SRC_ROOTFS="${1:?missing ; see 'mkruntime.sh --help'}" +OUT="${2:?missing ; see 'mkruntime.sh --help'}" + +# Knobs (env-overridable; the defaults preserve the reproducible, PCR14-stable payload). +: "${SOURCE_DATE_EPOCH:=0}"; export SOURCE_DATE_EPOCH +: "${DATA_SIZE_MB:=64}" +: "${ROOTFS_UUID:=4c4f434b-0000-0000-0000-000000000002}" +: "${VERITY_SALT:=6c6f636b626f6f742d72756e74696d652d766572697479732d73616c742d3031}" work="$(mktemp -d)" trap 'rm -rf "$work"' EXIT -mkdir -p "$work/root" "$OUT" +mkdir -p "$work/root" -# Rootfs source: a single binary becomes a flat erofs root with that binary as /init (the loader -# execs /init); a directory is taken as a full container rootfs verbatim (docker->erofs front end). -if [ -d "$SRC_ROOTFS" ]; then - cp -a "$SRC_ROOTFS/." "$work/root/" +# Rootfs source -> $work/root. Four shapes; the tar-on-stdin form keeps docker out of this script +# (see the `docker export` example in --help). The loader execs /init, so a bare binary lands there. +if [ "$SRC_ROOTFS" = "-" ]; then + tar -x -C "$work/root" # tar stream on stdin +elif [ -d "$SRC_ROOTFS" ]; then + cp -a "$SRC_ROOTFS/." "$work/root/" # directory, verbatim +elif tar -tf "$SRC_ROOTFS" >/dev/null 2>&1; then + tar -x -C "$work/root" -f "$SRC_ROOTFS" # a tar archive file else - install -m 0755 "$SRC_ROOTFS" "$work/root/init" + install -m 0755 "$SRC_ROOTFS" "$work/root/init" # single executable -> /init fi -# 1) erofs (deterministic): 4096-byte blocks, fixed UUID + timestamp. -mkfs.erofs -b 4096 -U "$FIXED_UUID" -T "$SOURCE_DATE_EPOCH" \ +# 1) erofs (deterministic): 4096-byte blocks (matches the runtime verity block size), UUID + epoch. +mkfs.erofs -b 4096 -U "$ROOTFS_UUID" -T "$SOURCE_DATE_EPOCH" \ "$work/rootfs.erofs" "$work/root" >/dev/null E="$(stat -c %s "$work/rootfs.erofs")" @@ -49,28 +95,52 @@ DATA_BLOCKS=$((E / 4096)) ROOT_HASH="$(veritysetup format "$work/rootfs.erofs" "$work/rootfs.erofs" \ --no-superblock --hash-offset="$E" \ --data-block-size=4096 --hash-block-size=4096 --data-blocks="$DATA_BLOCKS" \ - --hash=sha256 --salt="$FIXED_SALT" --format=1 \ + --hash=sha256 --salt="$VERITY_SALT" --format=1 \ | awk '/Root hash/ {print $NF}')" [ -n "$ROOT_HASH" ] || { echo "veritysetup produced no root hash" >&2; exit 1; } -# 3) Empty journaled ext4 base for p3. Grown online to the partition at runtime via -# EXT4_IOC_RESIZE_FS, so a modest base with resize_inode headroom is enough. Fully written -# (no lazy init) + fixed UUID/hash_seed for determinism. Modern mke2fs disables periodic -# fsck by default (max-mount-count -1, interval 0), which we rely on (no fsck ships). +# 3) Empty journaled ext4 base for p3. Grown online at runtime via EXT4_IOC_RESIZE_FS to fill p3. +# meta_bg: each ~8 GiB meta-block-group carries its own group descriptors (no central reserved +# GDT), so online growth is unbounded -- volume sizes are unknown. Fully written (no lazy init) + +# fixed UUID/hash_seed for determinism. Modern mke2fs disables periodic fsck by default (relied +# on; no fsck ships). mkfs.ext4 -q -F -b 4096 -m 0 \ - -O has_journal,extents,metadata_csum,64bit,resize_inode \ - -U "$FIXED_UUID" \ - -E "hash_seed=$FIXED_UUID,lazy_itable_init=0,lazy_journal_init=0,root_owner=0:0" \ - "$work/data-base.img" "$((DATA_EXT4_SIZE_MB * 256))" + -O has_journal,extents,metadata_csum,64bit,meta_bg,^resize_inode \ + -U "$ROOTFS_UUID" \ + -E "hash_seed=$ROOTFS_UUID,lazy_itable_init=0,lazy_journal_init=0,root_owner=0:0" \ + "$work/data-base.img" "$((DATA_SIZE_MB * 256))" printf '{"root_hash":"%s","salt":"%s","data_blocks":%s}\n' \ - "$ROOT_HASH" "$FIXED_SALT" "$DATA_BLOCKS" > "$work/verity.json" + "$ROOT_HASH" "$VERITY_SALT" "$DATA_BLOCKS" > "$work/verity.json" # 4) Deterministic zip: fixed mtimes + `zip -X` (drop uid/gid/atime extra fields), fixed order. # The zip deflates each entry (erofs a little, the mostly-zero ext4 base to ~nothing). touch -d "@$SOURCE_DATE_EPOCH" "$work/rootfs.erofs" "$work/data-base.img" "$work/verity.json" -rm -f "$OUT/payload.zip" ( cd "$work" && TZ=UTC zip -q -X -9 payload.zip rootfs.erofs data-base.img verity.json ) -mv "$work/payload.zip" "$OUT/payload.zip" -echo "payload.zip=$(stat -c %s "$OUT/payload.zip")B (rootfs.erofs=${E}B data_blocks=$DATA_BLOCKS) root=$ROOT_HASH" +# 5) Assemble the artifact: bare zip, or (with --bootstrap) loader ELF ++ zip, self-adjusted so the +# appended central directory offsets account for the ELF prefix (self-extracting). +if [ -n "$BOOTSTRAP" ]; then + [ -f "$BOOTSTRAP" ] || { echo "--bootstrap: no such ELF: $BOOTSTRAP" >&2; exit 1; } + cat "$BOOTSTRAP" "$work/payload.zip" > "$work/out" + zip -qA "$work/out" +else + mv "$work/payload.zip" "$work/out" +fi + +# 6) Emit to (a file, whose parent dir we create) or stdout (`-`). The summary goes to stderr +# so stdout stays clean when piping. +if [ "$OUT" = "-" ]; then + cat "$work/out" +else + mkdir -p "$(dirname "$OUT")" + cp "$work/out" "$OUT" + [ -n "$BOOTSTRAP" ] && chmod 0755 "$OUT" || : +fi + +SIZE="$(stat -c %s "$work/out")" +if [ -n "$BOOTSTRAP" ]; then + echo "stage2=${SIZE}B (loader+payload; rootfs.erofs=${E}B data_blocks=$DATA_BLOCKS) root=$ROOT_HASH" >&2 +else + echo "payload.zip=${SIZE}B (rootfs.erofs=${E}B data_blocks=$DATA_BLOCKS) root=$ROOT_HASH" >&2 +fi From 9ddedb593f6c8d14d8602520309ab4a126c793d2 Mon Sep 17 00:00:00 2001 From: HaRoLd <303926+HarryR@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:19:23 +0000 Subject: [PATCH 2/3] tooling: mkruntime.sh as erofs-builder entrypoint + slim image; README restructure Make lockboot:erofs-builder a one-shot CLI: bake tools/mkruntime.sh in as the ENTRYPOINT, so packaging no longer needs a repo bind-mount -- mount just the loader ELF, pipe a rootfs in on stdin, get the stage2 out on stdout. Drop coreutils (busybox covers touch -d @epoch, stat -c, tar, install, awk); output stays byte-identical (same root hash), image 20.3 -> 18.2 MB. Makefile pack passes args straight to the entrypoint. Restructure the README to lead with what it is and how to use it (the mkruntime one-liner with the built bootstrap), and move how-it-boots / security model / testing into a Technical details section. Co-Authored-By: Claude Opus 4.8 --- Dockerfile.erofs-builder | 21 +++++++++++----- Makefile | 1 - README.md | 53 ++++++++++++++++++++++++++++------------ 3 files changed, 53 insertions(+), 22 deletions(-) diff --git a/Dockerfile.erofs-builder b/Dockerfile.erofs-builder index 7a4e3ae..c0a757a 100644 --- a/Dockerfile.erofs-builder +++ b/Dockerfile.erofs-builder @@ -1,9 +1,15 @@ -# lockboot:erofs-builder — a BUILD-TIME-ONLY forge for the runtime-image artifacts. +# lockboot:erofs-builder — a BUILD-TIME-ONLY forge for the runtime-image artifacts. It wraps +# tools/mkruntime.sh as its entrypoint, so it is a one-shot CLI: # -# Carries mkfs.erofs (erofs-utils) + veritysetup (cryptsetup) + mkfs.ext4 (e2fsprogs). NONE of +# docker run --rm -i lockboot:erofs-builder --help +# docker export "$(docker create --rm img)" \ +# | docker run --rm -i -v "$PWD/bootstrap:/bootstrap:ro" lockboot:erofs-builder \ +# --bootstrap /bootstrap - - > stage2 # rootfs on stdin, stage2 on stdout +# +# Carries mkfs.erofs (erofs-utils) + veritysetup (cryptsetup) + mkfs.ext4 (e2fsprogs) + zip. NONE of # these ship to the instance: the runtime erofs image holds only static binaries, and p3's ext4 -# base is a baked artifact the kernel mounts/grows itself. This image exists purely to emit -# reproducible artifacts (see tools/mkruntime.sh). +# base is a baked artifact the kernel mounts/grows itself. busybox (the Alpine base) covers the rest +# of what the script uses -- touch -d @epoch, stat -c, tar, install, awk -- so no coreutils. # # Kept entirely separate from the canonical Dockerfile.build (which the top-level Makefile syncs # byte-identically into the harness repos and must stay lean) — nothing here touches it. Alpine @@ -15,7 +21,10 @@ RUN apk add --no-cache \ erofs-utils \ cryptsetup \ e2fsprogs \ - coreutils \ zip -WORKDIR /src/stage2 +COPY tools/mkruntime.sh /usr/local/bin/mkruntime +RUN chmod 0755 /usr/local/bin/mkruntime + +WORKDIR /work +ENTRYPOINT ["/usr/local/bin/mkruntime"] diff --git a/Makefile b/Makefile index b3b6528..40b2ee8 100644 --- a/Makefile +++ b/Makefile @@ -125,7 +125,6 @@ pack-%: docker-build-base docker-build-erofs $(DOCKER_RUN) $(DOCKER_SAMEUSER) \ -e SOURCE_DATE_EPOCH -e DATA_SIZE_MB -e ROOTFS_UUID -e VERITY_SALT \ $(EROFS_IMAGE) \ - sh tools/mkruntime.sh \ --bootstrap "target/$*-unknown-linux-musl/release/bootstrap" \ "target/$*-unknown-linux-musl/release/examples/$(INIT_PAYLOAD)" \ "build/$*/pack/stage2" diff --git a/README.md b/README.md index 069956a..0c42e50 100644 --- a/README.md +++ b/README.md @@ -14,15 +14,37 @@ By the time this runs, the layers below have admitted and verified it (sha256/ed A small, low-churn **loader** binary (`bootstrap`, static-musl, PID 1) with a container image **appended to it as a self-extracting zip**. The workload never touches the loader — you swap the appended image freely without recompiling the (audited, reproducible) loader. stage1 measures the -whole loader+image into **PCR 14**, so the image identity is attested for free. +whole loader+image into **PCR 14**, so the image identity is attested for free. Deployment is just +packaging: `docker export → mkfs.erofs → veritysetup → zip → append to the loader ELF`. -Deployment is just packaging: +## Using it +Build the loader and the packaging image once: + +```bash +make build-x86_64 docker-build-erofs +# build-x86_64 -> target/x86_64-unknown-linux-musl/release/bootstrap (the loader ELF) +# docker-build-erofs -> lockboot:erofs-builder (a one-shot CLI; mkruntime.sh is its entrypoint) ``` -docker export → mkfs.erofs → veritysetup → zip → append to the loader ELF + +The image *is* the tool. Mount just the loader ELF, pipe any container rootfs in on stdin, and the +bootable `stage2` comes out on stdout — no repo bind-mount, no host filesystem tools: + +```bash +docker export "$(docker create --rm myimage)" \ + | docker run --rm -i \ + -v "$PWD/target/x86_64-unknown-linux-musl/release/bootstrap:/bootstrap:ro" \ + lockboot:erofs-builder --bootstrap /bootstrap - - > stage2 ``` -## How it boots (loader = PID 1) +The rootfs argument (`-` above) can instead be a `.tar`, a directory, or a single binary (installed +as `/init`); the output (`-`) can be a path. `docker run --rm lockboot:erofs-builder --help` lists +the env knobs (`DATA_SIZE_MB`, `ROOTFS_UUID`, `VERITY_SALT`, `SOURCE_DATE_EPOCH`), all reproducible +by default. Serve the resulting `stage2` as your payload; stage0/stage1 admit it by `sha256`/`ed25519`. + +## Technical details + +### How it boots (loader = PID 1) 1. **Read its own payload** from `/proc/self/exe` (a memfd) — the appended zip carries `rootfs.erofs` (+ its dm-verity hash tree), a deterministic empty `data-base.img`, and @@ -42,34 +64,35 @@ docker export → mkfs.erofs → veritysetup → zip → append to the loader EL 8. **`switch_root`** onto the overlay and **`exec /init`** — the image's own entrypoint, handed the verbatim config on stdin. Transparent: stage2 imposes no config schema of its own. -## Security model +### Security model | Layer | Mechanism | Guarantee | |---|---|---| | **Code / rootfs** | erofs + dm-verity, measured into PCR 14, under an **ephemeral** overlay | Immutable, integrity-verified, re-attestable each boot — no persistent code modification | | **Config** | verbatim stdin JSON → hashed → PCR 15 | Disk key bound to config; any change ⇒ fresh disk (fail-closed) | -| **Data** | `/data`: dm-crypt `aes-xts-plain64` (AES-256-XTS) | Confidential. Confidentiality-only: at-rest tampering garbles plaintext rather than being cryptographically detected — media integrity is left to the storage layer (EBS et al.), and a raw-disk-write adversary is out of scope | +| **Data** | `/data`: dm-crypt `aes-xts-plain64` (AES-256-XTS) | Confidential (confidentiality-only): at-rest tamper garbles rather than being cryptographically detected — media integrity is left to the storage layer (EBS et al.) | | **Keys** | TPM-derived under a `PolicyPCR` session over a curated PCR set | No LUKS/header; drift in bound PCRs (code/config/firmware) ⇒ superblock decrypts to garbage ⇒ re-initialized (fail-closed) | See [`PCR-BINDING.md`](PCR-BINDING.md) for the bound PCR set, the {5,10} exclusions, and the IMA analysis. -## Build & run +### Building & testing + +Everything builds inside the shared `lockboot:build` image (no host toolchain); the runtime image is +forged in the build-time-only `lockboot:erofs-builder` — none of those tools ship to the instance. +Reproducible: the loader ELF and the payload zip are each byte-deterministic. ```bash make lock # generate Cargo.lock (first time) make check test # cargo check + test the workspace -make build-x86_64 # the loader (static musl) -make pack-x86_64 # loader + appended payload.zip -> build/x86_64/pack/stage2 -make test-pivot-x86_64 # boot the packed stage2 under the QEMU/swtpm harness +make ci # fmt + clippy + test (the CI gate) +make pack-x86_64 # pack the bundled example -> build/x86_64/pack/stage2 +make test-pivot-x86_64 # boot a packed stage2 under the QEMU/swtpm harness ``` -Everything builds inside the shared `lockboot:build` image (no host toolchain); the runtime image is -forged in a build-time-only `lockboot:erofs-builder` (`mkfs.erofs` + `veritysetup` + `mkfs.ext4` — -none of which ship to the instance). Reproducible: the loader ELF and the payload zip are each -byte-deterministic. `unzip -l build//pack/stage2` inspects a packed payload. +`unzip -l build//pack/stage2` inspects a packed payload. -## Crates +### Crates - **`bootstrap`** — the loader: read the appended payload, bring up persistence, pivot onto the overlay root, `exec /init`. From 6669086639bfd8cd3146aecdb26d1124e2cf933e Mon Sep 17 00:00:00 2001 From: HaRoLd <303926+HarryR@users.noreply.github.com> Date: Tue, 14 Jul 2026 07:09:35 +0000 Subject: [PATCH 3/3] ci: rename build job -> build-x86_64 / build-aarch64 Clean check-context names (no spaces/parens) so the uniform branch ruleset across the lockboot repos can require the same names everywhere. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index df4d8d8..c7ce88a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,6 +26,7 @@ jobs: # ---- Build + pack the loader for both arches ------------------------------ build: + name: build-${{ matrix.arch }} runs-on: ubuntu-latest needs: ci strategy: