Skip to content

Commit 77df428

Browse files
committed
fix(os): harden AWS guest image release validation
1 parent 2f609a3 commit 77df428

9 files changed

Lines changed: 102 additions & 68 deletions

File tree

docs/aws-ec2-production-verifier-runbook.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,8 @@ After the release artifact hashes and AWS PCR references match the published
7474
release evidence, deploy with **`dstack-cloud`** (`platform: aws`). The CLI
7575
imports the local UKI `disk.raw` as an Attestable AMI (UEFI + NitroTPM v2.0) when
7676
`aws_config.ami_id` is empty, builds the shared config disk with
77-
`aws_measurement` from `measurement.aws.cbor`, and launches the instance.
77+
`aws_measurement` from `measurement.aws.cbor`, imports a minimal GPT data-disk
78+
template whose partition is labeled `dstack-data`, and launches the instance.
7879

7980
```bash
8081
# one-time: install CLI
@@ -88,21 +89,23 @@ cd my-aws-app
8889

8990
dstack-cloud pull dstack-0.6.0 # UKI package must include assemble-time measurement.aws.cbor
9091
dstack-cloud prepare # embeds fixed os_image_hash + aws_measurement (no PCR recompute)
91-
dstack-cloud deploy # import AMI if needed, import shared snapshot, run-instances
92+
dstack-cloud deploy # import AMI/shared/labeled-data snapshots, run-instances
9293

9394
dstack-cloud status
9495
dstack-cloud logs --follow
9596
```
9697

97-
Record the resulting AMI id, shared snapshot, and instance id from
98+
Record the resulting AMI id, shared/data snapshots, and instance id from
9899
`state.json` / `dstack-cloud status` in the release review package.
99100

100101
Recompute AWS reference PCRs and the UKI AuthentiCode hash from the release
101102
UKI with version-pinned host tools (do not rely on an unpinned container
102103
toolchain for reference measurements):
103104

104105
```bash
105-
cargo install --git https://github.com/aws/NitroTPM-Tools --locked nitro-tpm-pcr-compute
106+
cargo install --git https://github.com/aws/NitroTPM-Tools \
107+
--rev d76d6eeebd4169b00a3c3af9858852d48f40e748 \
108+
--locked nitro-tpm-pcr-compute # aws/NitroTPM-Tools v1.1.2
106109
# The UKI is EFI/BOOT/BOOTX64.EFI in the 256 MiB EFI partition at 1 MiB.
107110
dd if=published/disk.raw of=efi.img bs=1M skip=1 count=256 status=none
108111
mcopy -i efi.img ::EFI/BOOT/BOOTX64.EFI dstack-uki.efi

dstack/scripts/bin/dstack-cloud

Lines changed: 58 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,7 @@ class AwsConfig:
310310
boot_image_tar: str = "" # explicit path override
311311

312312
# Data disk
313-
data_size: int = 20 # GiB for new empty data volume
313+
data_size: int = 20 # GiB for the volume created from the labeled template
314314
data_snapshot: str = "" # optional existing snapshot for data disk
315315

316316
# Shared-disk device names (dstack host-shared + data)
@@ -324,7 +324,7 @@ class AwsConfig:
324324
security_group_ids: List[str] = field(default_factory=list)
325325
iam_instance_profile: str = "" # name or ARN
326326

327-
# S3 used when importing AMI / shared snapshot from local disk.raw
327+
# S3 used when importing AMI / shared / labeled-data snapshots from raw disks
328328
s3_bucket: str = ""
329329
s3_prefix: str = "dstack-import"
330330
import_role_name: str = "" # optional vmimport role override
@@ -384,6 +384,7 @@ class DeploymentState:
384384
instance_id: str = ""
385385
ami_id: str = ""
386386
shared_snapshot: str = ""
387+
data_snapshot: str = "" # Data template snapshot created by dstack-cloud
387388
external_ip: str = ""
388389
internal_ip: str = ""
389390
status: str = "" # RUNNING, STOPPED, TERMINATED, etc.
@@ -731,25 +732,7 @@ class CloudDeploymentManager:
731732

732733
# Create a minimal raw disk image with GPT partition table
733734
with tempfile.TemporaryDirectory() as tmpdir:
734-
raw_file = os.path.join(tmpdir, "disk.raw")
735-
736-
# Create a 10MB sparse file (enough for GPT)
737-
disk_size_bytes = 10 * 1024 * 1024
738-
with open(raw_file, 'wb') as f:
739-
f.truncate(disk_size_bytes)
740-
741-
# Create GPT partition table with dstack-data partition using sgdisk
742-
# -o: clear and create new GPT
743-
# -n 1:0:0: create partition 1, start at first available, end at last available
744-
# -c 1:dstack-data: set partition 1 name (PARTLABEL) to dstack-data
745-
result = subprocess.run(
746-
["sgdisk", "-o", "-n", "1:0:0", "-c", "1:dstack-data", raw_file],
747-
capture_output=True, text=True
748-
)
749-
if result.returncode != 0:
750-
raise RuntimeError(f"Failed to create GPT partition table: {result.stderr}")
751-
752-
logger.debug("Created GPT partition table with dstack-data label")
735+
self._build_data_raw_disk(Path(tmpdir))
753736

754737
# Compress to tar.gz for upload
755738
tar_file = os.path.join(tmpdir, "disk.tar.gz")
@@ -1697,6 +1680,29 @@ class CloudDeploymentManager:
16971680
logger.warning(".user-config not found, skipping")
16981681
return raw_file
16991682

1683+
def _build_data_raw_disk(self, work_path: Optional[Path] = None) -> Path:
1684+
"""Build a minimal GPT disk with a PARTLABEL=dstack-data partition."""
1685+
import shutil
1686+
1687+
if not shutil.which("sgdisk"):
1688+
raise FileNotFoundError(
1689+
"'sgdisk' command not found. Install the gdisk package."
1690+
)
1691+
if work_path is None:
1692+
work_path = Path(tempfile.mkdtemp(prefix="dstack-data-"))
1693+
work_path.mkdir(parents=True, exist_ok=True)
1694+
raw_file = work_path / "disk.raw"
1695+
subprocess.run(["truncate", "-s", "10M", str(raw_file)], check=True)
1696+
subprocess.run(
1697+
[
1698+
"sgdisk", "-o", "-n", "1:0:0", "-c", "1:dstack-data",
1699+
str(raw_file),
1700+
],
1701+
check=True,
1702+
capture_output=True,
1703+
)
1704+
return raw_file
1705+
17001706
def _aws_import_snapshot_from_raw(
17011707
self,
17021708
config: AwsConfig,
@@ -1931,14 +1937,31 @@ class CloudDeploymentManager:
19311937
pass
19321938

19331939
# Block device mappings: shared + data
1940+
managed_data_snapshot = ""
1941+
if config.data_snapshot:
1942+
data_snapshot = config.data_snapshot
1943+
else:
1944+
logger.info("building labeled data disk template...")
1945+
data_raw = self._build_data_raw_disk()
1946+
try:
1947+
data_token = f"data-{config.instance_name}-{int(time.time())}"[:64]
1948+
data_snapshot = self._aws_import_snapshot_from_raw(
1949+
config, data_raw, f"dstack-data-{config.instance_name}", data_token
1950+
)
1951+
managed_data_snapshot = data_snapshot
1952+
finally:
1953+
try:
1954+
import shutil
1955+
shutil.rmtree(data_raw.parent, ignore_errors=True)
1956+
except Exception:
1957+
pass
1958+
19341959
data_ebs: Dict[str, Any] = {
19351960
"DeleteOnTermination": True,
19361961
"VolumeType": config.volume_type,
1962+
"SnapshotId": data_snapshot,
1963+
"VolumeSize": int(config.data_size),
19371964
}
1938-
if config.data_snapshot:
1939-
data_ebs["SnapshotId"] = config.data_snapshot
1940-
else:
1941-
data_ebs["VolumeSize"] = int(config.data_size)
19421965

19431966
mappings = [
19441967
{
@@ -2019,6 +2042,7 @@ class CloudDeploymentManager:
20192042
instance_id=instance_id,
20202043
ami_id=ami_id,
20212044
shared_snapshot=shared_snapshot,
2045+
data_snapshot=managed_data_snapshot,
20222046
external_ip=external_ip,
20232047
internal_ip=internal_ip,
20242048
status=inst.get("State", {}).get("Name", "running").upper(),
@@ -2032,6 +2056,8 @@ class CloudDeploymentManager:
20322056
logger.info(f"Instance ID: {instance_id}")
20332057
logger.info(f"AMI: {ami_id}")
20342058
logger.info(f"Shared snapshot: {shared_snapshot}")
2059+
if managed_data_snapshot:
2060+
logger.info(f"Data template snapshot: {managed_data_snapshot}")
20352061
logger.info(f"Public IP: {external_ip or '(none)'}")
20362062
logger.info(f"Private IP: {internal_ip or '(none)'}")
20372063
logger.info("")
@@ -2758,6 +2784,13 @@ class CloudDeploymentManager:
27582784
"--region", state.region,
27592785
"--snapshot-id", state.shared_snapshot,
27602786
], capture=True, check=False)
2787+
if not keep_images and state.data_snapshot:
2788+
logger.info(f"Deleting data template snapshot {state.data_snapshot}...")
2789+
self._run_aws([
2790+
"ec2", "delete-snapshot",
2791+
"--region", state.region,
2792+
"--snapshot-id", state.data_snapshot,
2793+
], capture=True, check=False)
27612794
state_path = self.work_dir / STATE_FILE
27622795
if state_path.exists():
27632796
state_path.unlink()

dstack/tpm2/src/device.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,10 @@ mod tests {
320320
let bytes = cmd.finalize();
321321

322322
assert_eq!(&bytes[0..2], &[0x80, 0x02]);
323-
assert_eq!(u32::from_be_bytes(bytes[2..6].try_into().unwrap()), 25);
323+
assert_eq!(
324+
u32::from_be_bytes([bytes[2], bytes[3], bytes[4], bytes[5]]),
325+
25
326+
);
324327
assert_eq!(&bytes[6..10], &[0x20, 0x00, 0x00, 0x01]);
325328
assert_eq!(&bytes[10..14], &[0x01, 0x80, 0x10, 0x00]);
326329
assert_eq!(&bytes[14..18], &[0x01, 0x80, 0x10, 0x00]);
@@ -358,7 +361,10 @@ mod tests {
358361
0x00, 0x00, 0x00, 0x00, // TPM_RC_SUCCESS
359362
];
360363

361-
let parsed = TpmResponse::parse(&response).unwrap();
364+
let parsed = match TpmResponse::parse(&response) {
365+
Ok(parsed) => parsed,
366+
Err(error) => panic!("failed to parse minimal TPM response: {error}"),
367+
};
362368
assert!(parsed.is_success());
363369
assert!(parsed.data.is_empty());
364370
}

os/common/rootfs/dstack-prepare.sh

Lines changed: 5 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -149,31 +149,6 @@ has_luks_header() {
149149
return 1
150150
}
151151

152-
create_data_partition() {
153-
local disk="$1"
154-
log "Creating GPT partition table on ${disk}..."
155-
if ! command -v sgdisk >/dev/null 2>&1; then
156-
log "Error: sgdisk not available, cannot create partition table"
157-
return 1
158-
fi
159-
# Create GPT with single partition filling entire disk
160-
sgdisk -Z "$disk" >/dev/null || true # Zap any existing data
161-
sgdisk -n 1:1MiB:0 -c 1:dstack-data -t 1:8300 "$disk" >/dev/null || return 1
162-
# Trigger kernel to re-read partition table
163-
blockdev --rereadpt "$disk" >/dev/null || true
164-
udevadm settle >/dev/null || sleep 1
165-
part_device=$(
166-
lsblk -nr -o PATH "$disk" 2>/dev/null | sed -n '2p'
167-
)
168-
if [ -n "$part_device" ] && [ -b "$part_device" ]; then
169-
log "Created partition: $part_device"
170-
echo "$part_device"
171-
return 0
172-
fi
173-
log "Failed to create partition"
174-
return 1
175-
}
176-
177152
choose_data_device() {
178153
local override="$1"
179154
local dev=""
@@ -216,16 +191,11 @@ choose_data_device() {
216191
return 1
217192
fi
218193

219-
# 3.3. /dev/vdb is empty, create partition table
220-
log "Empty disk detected at /dev/vdb, creating dstack-data partition..."
221-
local new_partition
222-
new_partition=$(create_data_partition /dev/vdb)
223-
if [ -z "$new_partition" ]; then
224-
log "Error: Failed to create partition on /dev/vdb"
225-
return 1
226-
fi
227-
echo "$new_partition"
228-
return 0
194+
# Do not guess which blank disk is persistent storage or destructively
195+
# initialize it. Cloud deployment tooling must provision PARTLABEL=dstack-data.
196+
log "Error: Empty /dev/vdb has no 'dstack-data' partition"
197+
log "Provision a labeled data-disk image or specify dstack.data_device"
198+
return 1
229199
}
230200

231201
DATA_DEVICE_OVERRIDE=$(get_cmdline_value "dstack.data_device" || true)

os/image/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ Deploy tooling (`dstack-cloud prepare`) only **embeds** these files into
2323
AWS PCR precompute requires a pinned host `nitro-tpm-pcr-compute` binary (Rust,
2424
[aws/NitroTPM-Tools](https://github.com/aws/NitroTPM-Tools)). Set
2525
`NITRO_TPM_PCR_COMPUTE_BIN` or install it on `PATH`, for example with
26-
`cargo install --git https://github.com/aws/NitroTPM-Tools --locked nitro-tpm-pcr-compute`.
26+
`cargo install --git https://github.com/aws/NitroTPM-Tools --rev d76d6eeebd4169b00a3c3af9858852d48f40e748 --locked nitro-tpm-pcr-compute`
27+
(aws/NitroTPM-Tools v1.1.2).
2728
If it is missing, UKI assembly fails.
2829

2930
`mk-image-mr.sh <release.tar.gz>` creates the flattened, rootfs-free

os/image/assemble.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -459,7 +459,7 @@ if [[ "$UKI_CREATED" = "1" ]]; then
459459
if [[ -z "$pcr_compute_bin" ]]; then
460460
echo "Error: cannot produce measurement.aws.cbor for UKI image." >&2
461461
echo "Install the pinned host tool:" >&2
462-
echo " cargo install --git https://github.com/aws/NitroTPM-Tools --locked nitro-tpm-pcr-compute" >&2
462+
echo " cargo install --git https://github.com/aws/NitroTPM-Tools --rev d76d6eeebd4169b00a3c3af9858852d48f40e748 --locked nitro-tpm-pcr-compute" >&2
463463
echo " # or set NITRO_TPM_PCR_COMPUTE_BIN=/path/to/nitro-tpm-pcr-compute" >&2
464464
echo "measurement.aws.cbor must be fixed at assemble time for a stable os_image_hash." >&2
465465
exit 1

os/yocto/layers/meta-dstack/recipes-kernel/linux/files/dstack.cfg

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,15 @@ CONFIG_WLAN=n
4747
# "verity: Cannot initialize hash function (-ENOENT)".
4848
CONFIG_CRYPTO_SHA256=y
4949
CONFIG_CRYPTO_SHA512=y
50+
51+
# Production kernel self-protection baseline.
52+
CONFIG_SECURITY_DMESG_RESTRICT=y
53+
CONFIG_IO_STRICT_DEVMEM=y
54+
CONFIG_INIT_ON_ALLOC_DEFAULT_ON=y
55+
CONFIG_INIT_ON_FREE_DEFAULT_ON=y
56+
CONFIG_SLAB_FREELIST_RANDOM=y
57+
CONFIG_SLAB_FREELIST_HARDENED=y
58+
CONFIG_HARDENED_USERCOPY=y
59+
CONFIG_FORTIFY_SOURCE=y
60+
CONFIG_MAGIC_SYSRQ=n
61+
CONFIG_LEGACY_TIOCSTI=n

os/yocto/repro-build/Dockerfile.repro

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ ENV RUSTUP_HOME=/usr/local/rustup \
1111
CARGO_HOME=/usr/local/cargo \
1212
PATH=/usr/local/cargo/bin:$PATH
1313

14+
# aws/NitroTPM-Tools v1.1.2. This binary contributes release measurements, so
15+
# pin the source revision rather than following the repository default branch.
16+
ARG NITRO_TPM_TOOLS_REV=d76d6eeebd4169b00a3c3af9858852d48f40e748
17+
1418
# Set timezone
1519
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
1620

@@ -26,6 +30,7 @@ RUN apt update && apt install -y \
2630
ca-certificates \
2731
curl \
2832
git \
33+
jq \
2934
file \
3035
gawk \
3136
wget \
@@ -42,6 +47,8 @@ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
4247
sh -s -- -y --profile minimal --default-toolchain 1.92 && \
4348
rustup component add rustfmt clippy rust-analyzer && \
4449
rustup target add wasm32-unknown-unknown x86_64-unknown-linux-musl thumbv6m-none-eabi && \
50+
cargo install --git https://github.com/aws/NitroTPM-Tools \
51+
--rev "$NITRO_TPM_TOOLS_REV" --locked nitro-tpm-pcr-compute && \
4552
chmod -R a+rX "$RUSTUP_HOME" "$CARGO_HOME" && \
4653
cargo --version && \
4754
rustc --version

os/yocto/repro-build/check.sh

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,14 @@ COMPARE_IMAGE_WHITELIST=(
3131
"initramfs.cpio.gz"
3232
"metadata.json"
3333
"measurement.gcp.cbor"
34+
"measurement.aws.cbor"
3435
"measurement.snp.cbor"
3536
"measurement.tdx.cbor"
3637
"ovmf.fd"
3738
"rootfs.img.parted.verity"
3839
"sha256sum.txt"
3940
"auth_hash.txt"
41+
"aws-pcrs.json"
4042
"gcp/efi-root/EFI/BOOT/BOOTX64.EFI"
4143
)
4244

0 commit comments

Comments
 (0)