Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/build-and-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ jobs:

build:
name: ${{ matrix.distro }}-${{ matrix.release }}
needs: create-release
runs-on: ubuntu-24.04
strategy:
fail-fast: false
Expand Down
2 changes: 2 additions & 0 deletions modules/build/guest/mkosi.conf
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,5 @@ Include=../../stop/guest

[Content]
KernelCommandLine=console=ttyS0
Packages=
python3
60 changes: 60 additions & 0 deletions modules/launch/host/generate-id-block/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# generate-id-block

Host-image module that patches and signs the ID block template immediately
before guest launch.

The unsigned template that this module operates on is built by the browser-based
tool described in [`tools/id-block-README.md`](../../../../tools/id-block-README.md).

## What It Does

`generate_id_block.py` runs as a oneshot systemd service after
`calculate-measurement.service` and before `launch-guest.service`. It:

1. Reads the guest measurement from `guest_measurement.txt` (48-byte SHA-384,
written as `0x<96 hex chars>`)
2. Decodes the ID block template from `id-block.b64` (must be exactly 96 bytes)
3. Patches bytes 0–47 (the `ld` field) with the actual guest measurement
4. Generates an ephemeral P-384 key pair
5. Signs the patched ID block with ECDSA-P384-SHA384
6. Writes the signed ID block back to `id-block.b64`
7. Writes a 4096-byte `ID_AUTH_INFO` structure to `id-auth.b64` containing the
signature and the ephemeral public key (no author key)

Both output files are then consumed by `launch-guest.sh`, which reads the policy
from `id-block.b64` and passes all three values (`policy=`, `id-block=`,
`id-auth=`) to QEMU's `sev-snp-guest` object.

## Why Ephemeral Keys

The ID block exists to set policy and metadata bounds on the guest — it is not
intended to assert a persistent identity for this particular launch. An ephemeral
key per launch is sufficient: the firmware verifies the signature is internally
consistent (the ID block is signed by the key in ID auth), not that the key
belongs to any particular owner.

If persistent ID key identity is needed in the future, the script would need to
accept a pre-generated key rather than generating one.

## File Locations

| File | Path |
|------|------|
| Input: measurement | `/usr/local/lib/guest-image/guest_measurement.txt` |
| Input/output: ID block | `/usr/local/lib/guest-image/id-block.b64` |
| Output: ID auth | `/usr/local/lib/guest-image/id-auth.b64` |
| Script | `/usr/local/lib/scripts/generate_id_block.py` |

## Service Ordering

```
calculate-measurement.service
generate-id-block.service ← this module
launch-guest.service
```

## Dependencies

Requires `python3-cryptography` (installed via `mkosi.conf`).
3 changes: 3 additions & 0 deletions modules/launch/host/generate-id-block/mkosi.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[Content]
Packages=
python3-cryptography
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""Patch the ld field of id-block.b64 with the guest measurement, sign it,
and generate id-auth.b64 with the signature and ephemeral ID key."""

import base64
import os
import struct
import sys

from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
from cryptography.hazmat.primitives import hashes

MEASUREMENT_FILE = "/usr/local/lib/guest-image/guest_measurement.txt"
ID_BLOCK_FILE = "/usr/local/lib/guest-image/id-block.b64"
ID_AUTH_FILE = "/usr/local/lib/guest-image/id-auth.b64"

# AMD SEV-SNP ABI constants
ALGO_ECDSA_P384_SHA384 = 1
CURVE_P384 = 2


def int_to_le(value, length):
"""Convert an integer to little-endian bytes, zero-padded to length."""
return value.to_bytes(length, "big")[::-1]


def build_ecdsa_sig(r_int, s_int):
"""Build SEV_ECDSA_SIG (512 bytes): r[72] + s[72] + reserved[368]."""
r = int_to_le(r_int, 48).ljust(72, b"\x00")
s = int_to_le(s_int, 48).ljust(72, b"\x00")
return r + s + b"\x00" * 368


def build_ecdsa_pub_key(pub_numbers):
"""Build SEV_ECDSA_PUB_KEY (1028 bytes): curve[4] + qx[72] + qy[72] + reserved[880]."""
qx = int_to_le(pub_numbers.x, 48).ljust(72, b"\x00")
qy = int_to_le(pub_numbers.y, 48).ljust(72, b"\x00")
return struct.pack("<I", CURVE_P384) + qx + qy + b"\x00" * 880


def main():
# If the measurement file doesn't exist, calculate-measurement.service
# was skipped (e.g. AMDSEV OVMF not present). Nothing to do.
if not os.path.exists(MEASUREMENT_FILE):
print(f"INFO: {MEASUREMENT_FILE} not found — skipping ID block generation")
sys.exit(0)

# Read measurement (format: 0x<96 hex chars> = 48 raw bytes)
measurement_text = open(MEASUREMENT_FILE).read().strip()
hex_str = measurement_text.removeprefix("0x")
if len(hex_str) != 96:
print(
f"ERROR: unexpected measurement length {len(hex_str)} hex chars (expected 96)",
file=sys.stderr,
)
sys.exit(1)
ld = bytes.fromhex(hex_str)

# Decode existing ID block template, patch ld at offset 0
id_block = bytearray(base64.b64decode(open(ID_BLOCK_FILE).read().strip()))
if len(id_block) != 96:
print(
f"ERROR: unexpected id-block length {len(id_block)} bytes (expected 96)",
file=sys.stderr,
)
sys.exit(1)
id_block[0:48] = ld

# Generate ephemeral P-384 key pair
private_key = ec.generate_private_key(ec.SECP384R1())
public_key = private_key.public_key()

# Sign the patched ID block
der_sig = private_key.sign(bytes(id_block), ec.ECDSA(hashes.SHA384()))
r_int, s_int = decode_dss_signature(der_sig)

# Build ID_AUTH_INFO_STRUCT (4096 bytes)
# 0x000: id_key_algo (u32)
# 0x004: auth_key_algo (u32)
# 0x008: reserved (56 bytes)
# 0x040: id_block_sig (512 bytes)
# 0x240: id_key (1028 bytes)
# 0x644: reserved (60 bytes)
# 0x680: author_key_sig (512 bytes, zeros)
# 0x880: author_key (1028 bytes, zeros)
# 0xC84: reserved (892 bytes)
id_auth = bytearray(4096)
struct.pack_into("<I", id_auth, 0x000, ALGO_ECDSA_P384_SHA384) # id_key_algo
# auth_key_algo = 0 (no author key), already zero
id_auth[0x040 : 0x040 + 512] = build_ecdsa_sig(r_int, s_int)
id_auth[0x240 : 0x240 + 1028] = build_ecdsa_pub_key(public_key.public_numbers())

# Write outputs
open(ID_BLOCK_FILE, "w").write(base64.b64encode(bytes(id_block)).decode())
open(ID_AUTH_FILE, "w").write(base64.b64encode(bytes(id_auth)).decode())

print(f"Patched {ID_BLOCK_FILE} with measurement ld={hex_str[:16]}...")
print(f"Generated {ID_AUTH_FILE} with ephemeral ID key signature")


if __name__ == "__main__":
main()
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
[Unit]
Description=Patch guest measurement into id-block.b64
DefaultDependencies=no
After=calculate-measurement.service
Wants=calculate-measurement.service
Before=launch-guest.service

[Service]
Type=oneshot
ExecStart=/usr/local/lib/scripts/generate_id_block.py
StandardOutput=journal+console
StandardError=journal+console
LogExtraFields="SEV_VERSION=3.0.0-0" "SNPHOST_TEST=3.0.0-0"
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
Description=Barrier that triggers the launch services
DefaultDependencies=no

Requires=calculate-measurement.service launch-guest.service verify-guest.service
After=calculate-measurement.service launch-guest.service verify-guest.service
Requires=calculate-measurement.service generate-id-block.service launch-guest.service verify-guest.service
After=calculate-measurement.service generate-id-block.service launch-guest.service verify-guest.service

[Service]
Type=oneshot
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD60AAAAAAAAAAAAAAAAAAArtABAAAAMAAAAAAACwAAAAAA
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,21 @@ set -euo pipefail

EFI_PATH="/usr/local/lib/guest-image/guest.efi"
MEASUREMENT_FILE="/usr/local/lib/guest-image/guest_measurement.txt"
ID_BLOCK_FILE="/usr/local/lib/guest-image/id-block.b64"
ID_AUTH_FILE="/usr/local/lib/guest-image/id-auth.b64"
GUEST_ERROR_LOG="/tmp/guest-error.log"
EXTRA_QEMU_OPTS="${EXTRA_QEMU_OPTS:-}"

# Verbose mode: -v flag or LAUNCH_GUEST_VERBOSE=1 env var
VERBOSE="${LAUNCH_GUEST_VERBOSE:-0}"
while getopts "v" opt; do
case $opt in
v) VERBOSE=1 ;;
*) echo "Usage: $0 [-v]" >&2; exit 1 ;;
esac
done

dbg() { [ "${VERBOSE}" -eq 1 ] && echo "[debug] $*" || true; }

# Check which OVMF binary to use
OVMF_PATH=""
Expand All @@ -15,33 +29,66 @@ for path in /usr/share/ovmf/OVMF.amdsev.fd /usr/share/edk2/ovmf/OVMF.amdsev.fd;
fi
done

if [ -z "${OVMF_PATH}" ] || [ ! -f "${OVMF_PATH}" ]; then
if [ -z "${OVMF_PATH}" ] || [ ! -f "${OVMF_PATH}" ]; then
echo "ERROR: AMDSEV compatible OVMF is not present, can't launch SEV enabled guest" >&2
exit 1
fi
dbg "OVMF: ${OVMF_PATH}"
dbg "EFI: ${EFI_PATH}"

# Convert measurement to the appropriate sha format to pass in as host data
calculated_measurement_hex=$(awk -F "0x" '{print $2}' "${MEASUREMENT_FILE}")
guest_measurement_sha256sum=$(echo "${calculated_measurement_hex}" | sha256sum | cut -d ' ' -f 1 | xxd -r -p | base64)
dbg "Measurement (hex): ${calculated_measurement_hex}"
dbg "Measurement (sha256): ${guest_measurement_sha256sum}"

# Convert Measurement to the appropriate sha format to pass in as host data
calculated_measurement_hex=$(awk -F "0x" '{print $2}' "${MEASUREMENT_FILE}" )
guest_measurement_sha256sum=$(echo "${calculated_measurement_hex}" | sha256sum | cut -d ' ' -f 1 | xxd -r -p | base64 )
# Build sev-snp-guest object; append ID block args if files are present
SEV_SNP_OBJECT="sev-snp-guest,id=sev0,cbitpos=51,reduced-phys-bits=1,kernel-hashes=on,host-data=${guest_measurement_sha256sum}"
if [ -f "${ID_BLOCK_FILE}" ] && [ -f "${ID_AUTH_FILE}" ]; then
ID_BLOCK_B64=$(cat "${ID_BLOCK_FILE}")
ID_AUTH_B64=$(cat "${ID_AUTH_FILE}")
# Extract policy from id-block (bytes 88-95, LE u64) so LAUNCH_START and
# LAUNCH_FINISH see the same value; without this QEMU uses its own default.
POLICY=$(base64 -d "${ID_BLOCK_FILE}" | python3 -c \
"import sys; d=sys.stdin.buffer.read(); print(hex(int.from_bytes(d[88:96],'little')))")
SEV_SNP_OBJECT="${SEV_SNP_OBJECT},policy=${POLICY},id-block=${ID_BLOCK_B64},id-auth=${ID_AUTH_B64}"
dbg "ID block: ${ID_BLOCK_FILE} (present)"
dbg "ID auth: ${ID_AUTH_FILE} (present)"
dbg "Policy: ${POLICY} (from id-block)"
else
dbg "ID block files not found — launching without ID block"
dbg " checked: ${ID_BLOCK_FILE}"
dbg " checked: ${ID_AUTH_FILE}"
fi
dbg "sev-snp-guest object: ${SEV_SNP_OBJECT}"

# Clean up the error trace before QEMU guest launch
truncate -s 0 ${GUEST_ERROR_LOG}
truncate -s 0 "${GUEST_ERROR_LOG}"

echo -e "\nSNP Guest boot is in progress ..."

# Launch the SNP guest in background
exec qemu-system-x86_64 \
-enable-kvm \
-machine q35 \
-cpu EPYC-v4 \
-machine memory-encryption=sev0 \
-monitor none \
-display none \
-object memory-backend-memfd,id=ram1,size=2048M \
-machine memory-backend=ram1 \
-object sev-snp-guest,id=sev0,cbitpos=51,reduced-phys-bits=1,kernel-hashes=on,host-data="${guest_measurement_sha256sum}" \
-bios ${OVMF_PATH} \
-kernel ${EFI_PATH} \
-netdev user,id=net0 \
-device virtio-net-pci,netdev=net0 2> ${GUEST_ERROR_LOG}
QEMU_CMD=(
qemu-system-x86_64
-enable-kvm
-machine q35
-cpu EPYC-v4
-machine memory-encryption=sev0
-monitor none
-display none
-object memory-backend-memfd,id=ram1,size=2048M
-machine memory-backend=ram1
-object "${SEV_SNP_OBJECT}"
-bios "${OVMF_PATH}"
-kernel "${EFI_PATH}"
)

# Append any extra QEMU options (word-split intentionally)
# shellcheck disable=SC2206
[ -n "${EXTRA_QEMU_OPTS}" ] && QEMU_CMD+=(${EXTRA_QEMU_OPTS})

if [ "${VERBOSE}" -eq 1 ]; then
echo "[debug] QEMU command:"
printf "[debug] %s\n" "${QEMU_CMD[@]}"
fi

exec "${QEMU_CMD[@]}" 2> "${GUEST_ERROR_LOG}"
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
[Unit]
Description=Launch a SNP enabled guest using qemu, the embeded guest image and the appropriate QEMU and OVMF, verify it launched.
DefaultDependencies=no
After=calculate-measurement.service
Wants=calculate-measurement.service
After=calculate-measurement.service generate-id-block.service
Wants=calculate-measurement.service generate-id-block.service

[Service]
Type=simple
Expand Down
1 change: 1 addition & 0 deletions modules/launch/host/mkosi.conf
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
[Include]
Include=./guest-measurement
Include=./generate-id-block
Include=./launch-guest
Include=./verify-guest
Include=./launch-done
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ while [[ $ELAPSED -lt $TIMEOUT ]]; do
ELAPSED=$((ELAPSED + INTERVAL))
done

echo -e "\nTimeout waiting for guest tests to complete."

# If timeout hits but logs are there, then show the logs.
guest_service_log=$(journalctl -D "${GUEST_JOURNAL_LOCATION}" "${args[@]}" -o cat)

Expand Down
Loading
Loading