From 5c8de37f6c8d0dbe7613194e82b7d61a60d2e4c5 Mon Sep 17 00:00:00 2001 From: Mark Gentry Date: Thu, 30 Apr 2026 08:42:41 -0500 Subject: [PATCH 1/3] feat: add SNP guest key derivation test --- modules/build/guest/mkosi.conf | 2 + .../local/lib/scripts/display-guest-logs.sh | 2 +- .../sev_certificate_version_3_0_0_0.py | 58 +- modules/test/guest/key-derivation/README.md | 154 ++++ .../lib/scripts/snpguest_key_derivation.py | 673 ++++++++++++++++++ .../lib/systemd/system/key-derivation.service | 12 + modules/test/guest/mkosi.conf | 1 + .../lib/systemd/system/test-done.service | 4 +- 8 files changed, 902 insertions(+), 4 deletions(-) create mode 100644 modules/test/guest/key-derivation/README.md create mode 100644 modules/test/guest/key-derivation/mkosi.extra/usr/local/lib/scripts/snpguest_key_derivation.py create mode 100644 modules/test/guest/key-derivation/mkosi.extra/usr/local/lib/systemd/system/key-derivation.service diff --git a/modules/build/guest/mkosi.conf b/modules/build/guest/mkosi.conf index 27ad54c3..b747d3c1 100644 --- a/modules/build/guest/mkosi.conf +++ b/modules/build/guest/mkosi.conf @@ -9,3 +9,5 @@ Include=../../stop/guest [Content] KernelCommandLine=console=ttyS0 +Packages= + python3 diff --git a/modules/report/host/display-guest-logs/mkosi.extra/usr/local/lib/scripts/display-guest-logs.sh b/modules/report/host/display-guest-logs/mkosi.extra/usr/local/lib/scripts/display-guest-logs.sh index a46e96ab..c86fb12b 100755 --- a/modules/report/host/display-guest-logs/mkosi.extra/usr/local/lib/scripts/display-guest-logs.sh +++ b/modules/report/host/display-guest-logs/mkosi.extra/usr/local/lib/scripts/display-guest-logs.sh @@ -7,7 +7,7 @@ TIMEOUT=60 INTERVAL=1 ELAPSED=0 -units=("snpguest-ok.service" "attestation-workflow.service") +units=("snpguest-ok.service" "attestation-workflow.service" "key-derivation.service") args=() for unit in "${units[@]}"; do diff --git a/modules/report/host/sev-certificate-generator/mkosi.extra/usr/local/lib/scripts/generate_sev_certificate/sev_certificate/sev_certificate_version_3_0_0_0.py b/modules/report/host/sev-certificate-generator/mkosi.extra/usr/local/lib/scripts/generate_sev_certificate/sev_certificate/sev_certificate_version_3_0_0_0.py index c7b145c5..03d95df9 100644 --- a/modules/report/host/sev-certificate-generator/mkosi.extra/usr/local/lib/scripts/generate_sev_certificate/sev_certificate/sev_certificate_version_3_0_0_0.py +++ b/modules/report/host/sev-certificate-generator/mkosi.extra/usr/local/lib/scripts/generate_sev_certificate/sev_certificate/sev_certificate_version_3_0_0_0.py @@ -109,6 +109,46 @@ def get_snp_guest_attestation_summary(self): return snpguest_attestation_summary + def get_key_derivation_summary(self): + """Generate SNP Guest Key Derivation summary from the key-derivation service. + + Returns: + Tuple of (formatted_summary_str, inferred_status_str). + inferred_status is "passed", "failed", or None if no JSON data found. + """ + + key_derivation_service = "key-derivation.service" + key_derivation_cmd = f"journalctl -D {self.guest_logs_path} -u {key_derivation_service} -o cat" + result = subprocess.run(key_derivation_cmd, shell=True, text=True, capture_output=True) + + # Extract and parse JSON objects (format: {"test_name": "0"/"1"}) + json_objects = re.findall(r'\{[^}]+\}', result.stdout) + + key_derivation_data = {} + for obj in json_objects: + try: + key_derivation_data.update(json.loads(obj)) + except (json.JSONDecodeError, ValueError): + pass + + if not key_derivation_data: + return '', None + + # Convert status codes to human-readable form (0=passed, non-zero=failed) + for step, status_code in key_derivation_data.items(): + key_derivation_data[step] = "passed" if int(status_code) == 0 else "failed" + + # Infer overall status: failed if any step failed + inferred_status = "failed" if any(s == "failed" for s in key_derivation_data.values()) else "passed" + + # Format output with test emojis + summary = '' + for step, step_status in key_derivation_data.items(): + emoji = test_status_emojis.get(step_status.lower(), '?') + summary += "\t\t\t " + f"{emoji} {step}" + "\n" + + return summary, inferred_status + def get_snp_guest_summary(self): """Generate all SNP Guest tests summary.""" @@ -118,6 +158,11 @@ def get_snp_guest_summary(self): snpguest_services = command.stdout snpguest_services_list = snpguest_services.splitlines() + # key-derivation.service may finish after other services and miss the journal + # upload window, so ensure it is always included even if discovery missed it. + if "key-derivation.service" not in snpguest_services_list: + snpguest_services_list.append("key-derivation.service") + # Map SNP Guest test service name with its status snpguest_services_status ={} @@ -130,17 +175,28 @@ def get_snp_guest_summary(self): snpguest_emoji = '' guest_attestation_summary = self.get_snp_guest_attestation_summary() + "\n" + key_derivation_summary, key_derivation_status = self.get_key_derivation_summary() + key_derivation_summary = (key_derivation_summary or '') + "\n" for service, service_status in snpguest_services_status.items(): + # For key-derivation.service, use JSON-inferred status when systemd lifecycle + # message is absent (shows as '?') due to journal-upload timing + if "key-derivation.service" in service.lower() and service_status == '?' and key_derivation_status: + service_status = key_derivation_status + emoji = test_status_emojis.get(service_status.lower(),'?') content += "\t" + f"{emoji} {service} :" service_description = self.sev_service.get_service_description(service, "guest") content += " " + service_description + "\n" # Add step-by-step summary status of the guest attestation workflow - if "attestation-workflow.service" in service.lower() : + if "attestation-workflow.service" in service.lower(): content += guest_attestation_summary + # Add step-by-step summary status of the key derivation tests + if "key-derivation.service" in service.lower(): + content += key_derivation_summary + # Set "snpguest_emoji" status based on the single failed/skipped SNP test if service_status.lower() == 'failed': snpguest_emoji = 'failed' diff --git a/modules/test/guest/key-derivation/README.md b/modules/test/guest/key-derivation/README.md new file mode 100644 index 00000000..5111b94c --- /dev/null +++ b/modules/test/guest/key-derivation/README.md @@ -0,0 +1,154 @@ +# SNP Guest Key Derivation Tests + +This guest-image module includes a Python-based systemd service that executes comprehensive key derivation tests on SNP-enabled guests using the [snpguest tool](https://github.com/virtee/snpguest.git). + +## Test Coverage + +The test suite validates the following key derivation properties: + +1. **Determinism**: Same parameters produce the same key +2. **VMPL Isolation**: Different VMPL values produce different keys (cryptographic isolation) +3. **Root Key Difference**: VCK and VMRK produce different keys +4. **Guest SVN Sensitivity**: Different guest SVN values produce different keys +5. **TCB Sensitivity**: Different TCB versions produce different keys +6. **Guest Field Select Sensitivity**: Different guest field select values produce different keys + +## Key Derivation Security Properties Tested + +### VMPL-Based Key Isolation + +The tests verify that the firmware correctly implements VMPL-based cryptographic isolation: +- Code at VMPL0 can derive keys tagged vmpl=0,1,2,3 +- Code at VMPL1 can derive keys tagged vmpl=1,2,3 (but NOT vmpl=0) +- Keys derived with different VMPL values are cryptographically distinct + +This prevents privilege escalation where compromised guest OS at VMPL1 attempts to access SVSM secrets at VMPL0. + +### Root Key Selection + +Tests validate that different root keys produce different derived keys: +- **VCK (Versioned Chip Key)**: Symmetric key derived from CEK+TCB +- **VMRK (VM Root Key)**: VM-specific symmetric key for migration scenarios + +Note: Despite the name "vcek" in the CLI, the root key selection uses **VCK** (symmetric), not VCEK (asymmetric signing key). + +## Test Architecture + +The test suite is implemented in Python and follows the same structure as the attestation tests: + +``` +key-derivation/ +├── README.md +└── mkosi.extra/ + └── usr/local/lib/ + ├── scripts/ + │ └── snpguest_key_derivation.py # Python test implementation + └── systemd/system/ + └── key-derivation.service # Systemd service unit +``` + +## Running the Tests + +### Prerequisites + +The tests can only run inside an SNP-enabled guest VM with: +- `/dev/sev-guest` device available +- `snpguest` tool installed +- Python 3.x available + +### Execution + +The tests run automatically via systemd service after boot. Manual execution: + +```bash +# Run the test suite +/usr/local/lib/scripts/snpguest_key_derivation.py + +# View test results +journalctl -u key-derivation.service + +# Check test status log +cat /usr/local/lib/key_derivation_status +``` + +### Test Output + +Each test produces: +- Status log in JSON format: `/usr/local/lib/key_derivation_status` +- Derived keys stored in: `/usr/local/lib/key_derivation_service/` +- Console output with pass/fail status and key values + +### Expected Output + +Successful test run: + +``` +====================================================================== +SNP Guest Key Derivation Test Suite +====================================================================== + +====================================================================== +TEST: Key Derivation Determinism +====================================================================== +✓ PASS: Keys match (deterministic) + Key: 0x + +====================================================================== +TEST: VMPL-Based Key Isolation +====================================================================== +✓ PASS: VMPL0 and VMPL1 keys differ (proper isolation) + VMPL0 Key: 0x + VMPL1 Key: 0x + +... + +====================================================================== +TEST SUMMARY +====================================================================== +✓ PASS: Determinism +✓ PASS: VMPL Isolation +✓ PASS: Root Key Difference +✓ PASS: Guest SVN Sensitivity +✓ PASS: TCB Sensitivity +✓ PASS: Guest Field Select Sensitivity + +Passed: 6/6 + +✓ All key derivation tests passed! +``` + +## Implementation Notes + +### Python vs Bash + +Unlike the attestation tests which use bash, this module uses Python for: +- Better error handling and structured output +- Type safety and code clarity +- Easier maintenance and extension +- Native JSON handling for status logs + +### VMPL Constraints + +The VMPL isolation test may produce warnings if running at VMPL > 0, as the firmware enforces that derived keys can only be requested for VMPL values ≥ current VMPL. This is expected behavior and validates the security constraint. + +### Key Display + +The tests use `snpguest display key` to read derived keys as hex strings for comparison. This avoids binary file comparison issues and provides human-readable output. + +## Integration with sev-certify + +To include key-derivation tests in the guest build, update the parent `mkosi.conf`: + +```conf +[Include] +Include=./attestation-result +Include=./attestation-workflow +Include=./key-derivation # Add this line +Include=./test-done +``` + +## References + +- [CLAUDE.md](../../../../../CLAUDE.md) - VCK/VCEK naming clarification +- [SEV-SNP-ARCHITECTURE.md](../../../../../SEV-SNP-ARCHITECTURE.md) - VMPL isolation details +- [snpguest documentation](https://github.com/virtee/snpguest) - Key derivation API diff --git a/modules/test/guest/key-derivation/mkosi.extra/usr/local/lib/scripts/snpguest_key_derivation.py b/modules/test/guest/key-derivation/mkosi.extra/usr/local/lib/scripts/snpguest_key_derivation.py new file mode 100644 index 00000000..0ab20c6e --- /dev/null +++ b/modules/test/guest/key-derivation/mkosi.extra/usr/local/lib/scripts/snpguest_key_derivation.py @@ -0,0 +1,673 @@ +#!/usr/bin/env python3 +""" +SNP Guest Key Derivation Tests + +This script tests the snpguest key derivation functionality, verifying: +1. Deterministic key generation (same params -> same key) +2. VMPL-based key isolation (different VMPL -> different keys) +3. Root key differences (VCK vs VMRK -> different keys) +4. Parameter sensitivity (different params -> different keys) + +By default only pass/fail status and summary are printed. Use --debug for +verbose output including snpguest commands and individual key values. +""" + +import argparse +import json +import re +import subprocess +import sys +from collections import defaultdict +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, List, Optional, Tuple + + +# Environment variables +KEY_DERIVATION_DIR = Path("/usr/local/lib/key_derivation_service") +KEY_DERIVATION_STATUS_LOG = Path("/usr/local/lib/key_derivation_status") + +# Set by parse_args(); used by dprint() +_debug: bool = False + + +def dprint(*args, **kwargs) -> None: + """Print only when --debug is active.""" + if _debug: + print(*args, **kwargs) + + +@dataclass +class TcbVersion: + """ + AMD SEV-SNP TCB_VERSION packed as a u64: + bits 7:0 - Boot Loader SVN + bits 15:8 - TEE SVN + bits 47:16 - Reserved (zero) + bits 55:48 - SNP firmware SVN + bits 63:56 - Microcode SVN + """ + boot_loader: int = 0 + tee: int = 0 + snp: int = 0 + microcode: int = 0 + + def to_u64(self) -> int: + return ( + (self.boot_loader & 0xFF) | + ((self.tee & 0xFF) << 8) | + ((self.snp & 0xFF) << 48) | + ((self.microcode & 0xFF) << 56) + ) + + def __str__(self) -> str: + return (f"bl=0x{self.boot_loader:02x} tee=0x{self.tee:02x} " + f"snp=0x{self.snp:02x} mc=0x{self.microcode:02x}") + + +@dataclass +class ReportInfo: + guest_svn: int = 0 + current_tcb: Optional[TcbVersion] = None + committed_tcb: Optional[TcbVersion] = None + reported_tcb: Optional[TcbVersion] = None + launch_tcb: Optional[TcbVersion] = None + + +def run_command(cmd: list[str], description: str) -> Tuple[int, str, str]: + """Execute a command and return (returncode, stdout, stderr).""" + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + return result.returncode, result.stdout, result.stderr + except subprocess.TimeoutExpired: + return -1, "", f"Command timed out: {description}" + except Exception as e: + return -1, "", f"Command failed: {description}: {str(e)}" + + +def check_command_status( + status: int, + command_name: str, + stdout: str, + stderr: str +) -> bool: + """Check command status, log to file, and print errors.""" + status_entry = {command_name: str(status)} + with open(KEY_DERIVATION_STATUS_LOG, 'a') as f: + json.dump(status_entry, f) + f.write('\n') + + if status != 0: + print(f"ERROR: {command_name} failed!", file=sys.stderr) + if stderr: + print(f"STDERR: {stderr}", file=sys.stderr) + if stdout: + print(f"STDOUT: {stdout}", file=sys.stderr) + return False + else: + if stdout: + dprint(stdout) + return True + + +def derive_key( + output_file: Path, + root_key: str = "vcek", + vmpl: int = 0, + guest_svn: int = 0, + tcb_version: int = 0, + guest_field_select: int = 1 +) -> bool: + """ + Derive a key using snpguest key command. + + Args: + output_file: Path to write the derived key + root_key: Root key selection ("vcek" or "vmrk") + vmpl: VMPL level (0-3) + guest_svn: Guest SVN value (must not exceed launch SVN from ID block) + tcb_version: TCB version value (packed u64; must not exceed CommittedTcb + per component; only mixed in when GFS bit 5 is set) + guest_field_select: Guest field select bitmap (GFS is always mixed in; + individual bits enable mixing specific guest fields) + + Returns: + True if successful, False otherwise + """ + cmd = [ + "snpguest", "key", + str(output_file), + root_key, + "--vmpl", str(vmpl), + "--guest_svn", str(guest_svn), + "--tcb_version", str(tcb_version), + "--guest_field_select", str(guest_field_select) + ] + + description = ( + f"Derive key: root={root_key}, vmpl={vmpl}, " + f"svn={guest_svn}, tcb=0x{tcb_version:016x}, gfs=0x{guest_field_select:02x}" + ) + + dprint(f"CMD: {' '.join(str(x) for x in cmd)}") + status, stdout, stderr = run_command(cmd, description) + return check_command_status(status, description, stdout, stderr) + + +def read_key_hex(key_file: Path) -> Optional[str]: + """Read a derived key file and return its contents as a hex string.""" + try: + return key_file.read_bytes().hex() + except Exception as e: + print(f"ERROR: Failed to read key from {key_file}: {e}", file=sys.stderr) + return None + + +def parse_tcb_section(section_text: str) -> TcbVersion: + """Parse boot_loader/TEE/SNP/microcode values from a TCB section of report output.""" + tcb = TcbVersion() + for attr, pattern in [ + ('boot_loader', r'Boot\s*Loader\s*[:\s]+(?:0x)?([0-9a-fA-F]+)'), + ('tee', r'TEE\s*[:\s]+(?:0x)?([0-9a-fA-F]+)'), + ('snp', r'SNP\s*[:\s]+(?:0x)?([0-9a-fA-F]+)'), + ('microcode', r'Microcode\s*[:\s]+(?:0x)?([0-9a-fA-F]+)'), + ]: + m = re.search(pattern, section_text, re.IGNORECASE) + if m: + setattr(tcb, attr, int(m.group(1), 16)) + return tcb + + +def parse_report_info(display_output: str) -> Optional[ReportInfo]: + """ + Parse snpguest display report output to extract guest SVN and TCB values. + + Returns None on complete failure; individual fields may be zero/None if + their section is missing or unparseable. + """ + try: + info = ReportInfo() + + m = re.search(r'Guest\s+SVN\s*[:\s]+(?:0x)?([0-9a-fA-F]+)', + display_output, re.IGNORECASE) + if m: + info.guest_svn = int(m.group(1), 16) + + boundary = r'(?:Current|Committed|Reported|Launch)\s+TCB' + for section_name, attr in [ + ('Current TCB', 'current_tcb'), + ('Committed TCB', 'committed_tcb'), + ('Reported TCB', 'reported_tcb'), + ('Launch TCB', 'launch_tcb'), + ]: + pattern = rf'{re.escape(section_name)}\s*:?(.*?)(?={boundary}|\Z)' + m = re.search(pattern, display_output, re.DOTALL | re.IGNORECASE) + if m: + setattr(info, attr, parse_tcb_section(m.group(1))) + + return info + except Exception as e: + print(f"WARNING: Failed to parse report info: {e}", file=sys.stderr) + return None + + +def print_attestation_report() -> Optional[ReportInfo]: + """ + Fetch and display the attestation report. + + Always prints the extracted key values (guest SVN, TCB bounds). + Full report text is printed only with --debug. + + Returns parsed ReportInfo, or None on failure. + """ + report_path = KEY_DERIVATION_DIR / "report.bin" + request_path = KEY_DERIVATION_DIR / "request.bin" + + print("\n" + "="*70) + print("ATTESTATION REPORT (reference values for key derivation bounds)") + print("="*70) + + cmd = ["snpguest", "report", str(report_path), str(request_path), "--random"] + dprint(f"CMD: {' '.join(cmd)}") + status, stdout, stderr = run_command(cmd, "Get attestation report") + if status != 0: + print("WARNING: Failed to get attestation report", file=sys.stderr) + if stderr: + print(f"STDERR: {stderr}", file=sys.stderr) + return None + + cmd = ["snpguest", "display", "report", str(report_path)] + dprint(f"CMD: {' '.join(cmd)}") + status, report_text, stderr = run_command(cmd, "Display attestation report") + if status != 0: + print("WARNING: Failed to display attestation report", file=sys.stderr) + if stderr: + print(f"STDERR: {stderr}", file=sys.stderr) + return None + + dprint(report_text) + + report_info = parse_report_info(report_text) + if report_info: + print(f" Guest SVN: {report_info.guest_svn} " + f"(upper bound for --guest_svn)") + if report_info.current_tcb: + print(f" Current TCB: {report_info.current_tcb}") + if report_info.committed_tcb: + print(f" Committed TCB: {report_info.committed_tcb} " + f"(upper bound per component for --tcb_version)") + if report_info.reported_tcb: + print(f" Reported TCB: {report_info.reported_tcb}") + if report_info.launch_tcb: + print(f" Launch TCB: {report_info.launch_tcb}") + else: + print(" WARNING: Could not parse report values", file=sys.stderr) + + return report_info + + +def generate_tcb_candidates(committed: TcbVersion, max_count: int = 30) -> List[int]: + """ + Generate up to max_count valid TCB u64 values. + + Varies each component (boot_loader, tee, snp, microcode) independently + from 0 to its committed maximum, keeping the other components at 0. + """ + candidates: set[int] = {0} + per_comp = max(1, (max_count - 1) // 4) + + for comp, max_val in [ + ('boot_loader', committed.boot_loader), + ('tee', committed.tee), + ('snp', committed.snp), + ('microcode', committed.microcode), + ]: + if max_val == 0 or len(candidates) >= max_count: + continue + step = max(1, max_val // per_comp) + for v in list(range(step, max_val, step)) + [max_val]: + tcb = TcbVersion() + setattr(tcb, comp, v) + candidates.add(tcb.to_u64()) + if len(candidates) >= max_count: + break + + return sorted(candidates)[:max_count] + + +def test_determinism() -> bool: + """Test that deriving a key with the same parameters produces the same result.""" + key1_file = KEY_DERIVATION_DIR / "determinism_key1.bin" + key2_file = KEY_DERIVATION_DIR / "determinism_key2.bin" + + if not derive_key(key1_file, root_key="vcek", vmpl=0, guest_svn=0, tcb_version=0): + return False + if not derive_key(key2_file, root_key="vcek", vmpl=0, guest_svn=0, tcb_version=0): + return False + + key1_hex = read_key_hex(key1_file) + key2_hex = read_key_hex(key2_file) + + if key1_hex is None or key2_hex is None: + print("ERROR: Failed to read keys for comparison", file=sys.stderr) + return False + + if key1_hex == key2_hex: + dprint(f" Key: 0x{key1_hex}") + print("✓ PASS: Keys match (deterministic)") + return True + else: + print("✗ FAIL: Keys do not match", file=sys.stderr) + dprint(f" Key1: 0x{key1_hex}", file=sys.stderr) + dprint(f" Key2: 0x{key2_hex}", file=sys.stderr) + return False + + +def test_vmpl_isolation() -> bool: + """Test that different VMPL values produce different keys.""" + key_vmpl0_file = KEY_DERIVATION_DIR / "vmpl0_key.bin" + key_vmpl1_file = KEY_DERIVATION_DIR / "vmpl1_key.bin" + + if not derive_key(key_vmpl0_file, root_key="vcek", vmpl=0): + return False + + if not derive_key(key_vmpl1_file, root_key="vcek", vmpl=1): + print(" Note: VMPL1 derivation failed (expected if not running at VMPL0)") + print("✓ PASS: N/A") + return True + + key_vmpl0_hex = read_key_hex(key_vmpl0_file) + key_vmpl1_hex = read_key_hex(key_vmpl1_file) + + if key_vmpl0_hex is None or key_vmpl1_hex is None: + print("ERROR: Failed to read keys for comparison", file=sys.stderr) + return False + + if key_vmpl0_hex != key_vmpl1_hex: + dprint(f" VMPL0 Key: 0x{key_vmpl0_hex}") + dprint(f" VMPL1 Key: 0x{key_vmpl1_hex}") + print("✓ PASS: VMPL0 and VMPL1 keys differ (proper isolation)") + return True + else: + print("✗ FAIL: VMPL0 and VMPL1 keys are identical", file=sys.stderr) + return False + + +def test_root_key_difference() -> bool: + """Test that different root keys (VCEK vs VMRK) produce different keys.""" + key_vck_file = KEY_DERIVATION_DIR / "vck_key.bin" + key_vmrk_file = KEY_DERIVATION_DIR / "vmrk_key.bin" + + if not derive_key(key_vck_file, root_key="vcek", vmpl=0): + return False + if not derive_key(key_vmrk_file, root_key="vmrk", vmpl=0): + return False + + key_vck_hex = read_key_hex(key_vck_file) + key_vmrk_hex = read_key_hex(key_vmrk_file) + + if key_vck_hex is None or key_vmrk_hex is None: + print("ERROR: Failed to read keys for comparison", file=sys.stderr) + return False + + if key_vck_hex != key_vmrk_hex: + dprint(f" VCK Key: 0x{key_vck_hex}") + dprint(f" VMRK Key: 0x{key_vmrk_hex}") + print("✓ PASS: VCEK and VMRK keys differ") + return True + else: + print("✗ FAIL: VCEK and VMRK keys are identical", file=sys.stderr) + return False + + +def test_guest_svn_sensitivity(report_info: Optional[ReportInfo]) -> bool: + """ + Test that different guest SVN values produce different keys. + + Loops over all valid SVN values (0..guest_svn from attestation report). + Upper bound is the guest SVN recorded at launch in the ID block; guests + launched without an ID block have guest_svn=0 (only one valid value). + GFS bit 4 must be set for guest_svn to be mixed into the derived key. + """ + max_svn = report_info.guest_svn if report_info is not None else 0 + print(f" Guest SVN upper bound: {max_svn}") + + svn_values = list(range(0, max_svn + 1)) + + if len(svn_values) < 2: + print(" Only one valid SVN value (0); sensitivity cannot be tested.") + print(" (Expected when guest was launched without an ID block.)") + print("✓ PASS: N/A (single valid value)") + return True + + print(f" Testing {len(svn_values)} SVN values: {svn_values}") + keys: Dict[int, str] = {} + for svn in svn_values: + key_file = KEY_DERIVATION_DIR / f"svn{svn}_key.bin" + if not derive_key(key_file, root_key="vcek", vmpl=0, guest_svn=svn, + guest_field_select=1 << 4): + print(f" WARNING: SVN={svn} derivation failed — skipping", file=sys.stderr) + continue + hex_key = read_key_hex(key_file) + if hex_key: + keys[svn] = hex_key + dprint(f" SVN={svn}: 0x{hex_key}") + + if len(keys) < 2: + print("ERROR: Fewer than 2 successful derivations — cannot test sensitivity", + file=sys.stderr) + return False + + unique_keys = set(keys.values()) + if len(unique_keys) == len(keys): + print(f"✓ PASS: All {len(keys)} SVN values produce distinct keys") + return True + else: + print("✗ FAIL: Some SVN values produce identical keys", file=sys.stderr) + return False + + +def test_tcb_sensitivity(report_info: Optional[ReportInfo]) -> bool: + """ + Test that different TCB version values produce different keys. + + Generates up to 30 valid TCB u64 values by varying each component + (boot_loader, tee, snp, microcode) from 0 to its committed maximum. + The firmware rejects tcb_version values where any component exceeds + the corresponding CommittedTcb component. + GFS bit 5 must be set for tcb_version to be mixed into the derived key. + """ + committed = (report_info.committed_tcb + if report_info is not None and report_info.committed_tcb is not None + else TcbVersion()) + + print(f" Committed TCB (upper bound per component): {committed}") + + candidates = generate_tcb_candidates(committed, max_count=30) + print(f" Testing {len(candidates)} TCB candidate(s)") + dprint(f" Candidates: {[f'0x{v:016x}' for v in candidates]}") + + if len(candidates) < 2: + print(" All TCB components are zero; sensitivity cannot be tested.") + print("✓ PASS: N/A (single valid value)") + return True + + keys: Dict[int, str] = {} + for tcb_u64 in candidates: + key_file = KEY_DERIVATION_DIR / f"tcb_{tcb_u64:016x}_key.bin" + if not derive_key(key_file, root_key="vcek", vmpl=0, tcb_version=tcb_u64, + guest_field_select=1 << 5): + print(f" WARNING: TCB=0x{tcb_u64:016x} derivation failed — skipping", + file=sys.stderr) + continue + hex_key = read_key_hex(key_file) + if hex_key: + keys[tcb_u64] = hex_key + dprint(f" TCB=0x{tcb_u64:016x}: 0x{hex_key}") + + if len(keys) < 2: + print("ERROR: Fewer than 2 successful derivations — cannot test sensitivity", + file=sys.stderr) + return False + + unique_keys = set(keys.values()) + if len(unique_keys) == len(keys): + print(f"✓ PASS: All {len(keys)} TCB values produce distinct keys") + return True + else: + print("✗ FAIL: Some TCB values produce identical keys", file=sys.stderr) + return False + + +def test_guest_field_select_sensitivity() -> bool: + """Test that different GFS values produce different keys.""" + key_gfs1_file = KEY_DERIVATION_DIR / "gfs1_key.bin" + key_gfs2_file = KEY_DERIVATION_DIR / "gfs2_key.bin" + + if not derive_key(key_gfs1_file, root_key="vcek", vmpl=0, guest_field_select=1): + return False + if not derive_key(key_gfs2_file, root_key="vcek", vmpl=0, guest_field_select=2): + return False + + key_gfs1_hex = read_key_hex(key_gfs1_file) + key_gfs2_hex = read_key_hex(key_gfs2_file) + + if key_gfs1_hex is None or key_gfs2_hex is None: + print("ERROR: Failed to read keys for comparison", file=sys.stderr) + return False + + if key_gfs1_hex != key_gfs2_hex: + dprint(f" GFS=0x01 Key: 0x{key_gfs1_hex}") + dprint(f" GFS=0x02 Key: 0x{key_gfs2_hex}") + print("✓ PASS: GFS=0x01 and GFS=0x02 keys differ") + return True + else: + print("✗ FAIL: GFS=0x01 and GFS=0x02 keys are identical", file=sys.stderr) + return False + + +def run_gfs_sweep() -> int: + """ + Derive a key for every valid GFS value (0x00-0x7f), keeping all other + parameters fixed (root=vcek, vmpl=0, svn=0, tcb=0). Shows which values + produce distinct keys and groups any that collide. + + snpguest accepts GFS up to 0x7f; bit 6 (launch mitigation vector) requires + msg v2 and may be rejected by some firmware. Failures are noted and skipped. + + Returns: + 0 always (diagnostic mode, not pass/fail) + """ + print("\n" + "="*70) + print("GFS SWEEP: all valid GFS values 0x00-0x7f") + print("Fixed params: root=vcek, vmpl=0, svn=0, tcb=0") + print("="*70) + + keys: Dict[int, str] = {} + failed: List[int] = [] + + for gfs in range(0x80): + key_file = KEY_DERIVATION_DIR / f"gfs_{gfs:02x}_key.bin" + if not derive_key(key_file, root_key="vcek", vmpl=0, + guest_svn=0, tcb_version=0, guest_field_select=gfs): + failed.append(gfs) + continue + hex_key = read_key_hex(key_file) + if hex_key: + keys[gfs] = hex_key + dprint(f" GFS=0x{gfs:02x}: 0x{hex_key}") + + print() + if failed: + print(f"Failed GFS values ({len(failed)}): " + f"{[f'0x{g:02x}' for g in failed]}") + + unique_keys = set(keys.values()) + print(f"{len(keys)} successful derivations, {len(unique_keys)} unique key(s)") + + key_to_gfs: Dict[str, List[int]] = defaultdict(list) + for gfs, hex_key in keys.items(): + key_to_gfs[hex_key].append(gfs) + + collisions = {k: v for k, v in key_to_gfs.items() if len(v) > 1} + if collisions: + print("\nGFS values producing identical keys:") + for hex_key, gfs_list in collisions.items(): + print(f" {[f'0x{g:02x}' for g in gfs_list]}: 0x{hex_key}") + + return 0 + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "SNP Guest Key Derivation Tests.\n" + "Runs the standard test suite by default.\n\n" + "Exit code: 0 = all tests passed, 1 = one or more tests failed." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "--debug", + action="store_true", + help=( + "Print verbose output including snpguest commands, " + "individual key hex values, and the full attestation report." + ), + ) + parser.add_argument( + "--gfs-sweep", + action="store_true", + help=( + "Instead of the standard test suite, derive a key for every valid " + "GFS value (0x00-0x7f) with all other params fixed " + "(root=vcek, vmpl=0, svn=0, tcb=0) and report which values " + "produce distinct keys." + ), + ) + return parser.parse_args() + + +def main() -> int: + """ + Main entry point. + + Returns: + 0 on success, 1 on failure + """ + global _debug + args = parse_args() + _debug = args.debug + + # Create fresh working directory + if KEY_DERIVATION_DIR.exists(): + import shutil + shutil.rmtree(KEY_DERIVATION_DIR) + KEY_DERIVATION_DIR.mkdir(parents=True, exist_ok=True) + + # Clear status log + if KEY_DERIVATION_STATUS_LOG.exists(): + KEY_DERIVATION_STATUS_LOG.unlink() + + if args.gfs_sweep: + return run_gfs_sweep() + + print("\n" + "="*70) + print("SNP Guest Key Derivation Test Suite") + print("="*70) + + # Fetch attestation report — provides bounds for SVN and TCB tests + report_info = print_attestation_report() + + # Run all tests + tests = [ + ("Determinism", lambda: test_determinism()), + ("VMPL Isolation", lambda: test_vmpl_isolation()), + ("Root Key Difference", lambda: test_root_key_difference()), + ("Guest SVN Sensitivity", lambda: test_guest_svn_sensitivity(report_info)), + ("TCB Sensitivity", lambda: test_tcb_sensitivity(report_info)), + ("Guest Field Select Sensitivity", lambda: test_guest_field_select_sensitivity()), + ] + + results = [] + for test_name, test_func in tests: + print("\n" + "="*70) + print(f"TEST: {test_name}") + print("="*70) + try: + passed = test_func() + results.append((test_name, passed)) + except Exception as e: + print(f"✗ EXCEPTION in {test_name}: {str(e)}", file=sys.stderr) + results.append((test_name, False)) + + # Print summary + print("\n" + "="*70) + print("TEST SUMMARY") + print("="*70) + + passed_count = sum(1 for _, passed in results if passed) + total_count = len(results) + + for test_name, passed in results: + print(f"{'✓ PASS' if passed else '✗ FAIL'}: {test_name}") + + print(f"\nPassed: {passed_count}/{total_count}") + + # Emit per-test JSON to stdout so the certificate generator can read it + # from the guest journal (journalctl -D /var/log/journal/guest-logs/ + # -u key-derivation.service -o cat). Format matches attestation-result.service. + print() + for test_name, passed in results: + print(json.dumps({test_name: "0" if passed else "1"})) + + if passed_count == total_count: + print("\n✓ All key derivation tests passed!") + return 0 + else: + print(f"\n✗ {total_count - passed_count} test(s) failed", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/modules/test/guest/key-derivation/mkosi.extra/usr/local/lib/systemd/system/key-derivation.service b/modules/test/guest/key-derivation/mkosi.extra/usr/local/lib/systemd/system/key-derivation.service new file mode 100644 index 00000000..97ac60c1 --- /dev/null +++ b/modules/test/guest/key-derivation/mkosi.extra/usr/local/lib/systemd/system/key-derivation.service @@ -0,0 +1,12 @@ +[Unit] +Description=Run SNP Key Derivation Tests after boot +DefaultDependencies=no +After=system.target +Wants=system.target + +[Service] +Type=oneshot +ExecStart=/usr/bin/python3 /usr/local/lib/scripts/snpguest_key_derivation.py +StandardOutput=journal+console +StandardError=journal+console +LogExtraFields="SEV_VERSION=3.0.0-0" "SNPGUEST_TEST=3.0.0-0" diff --git a/modules/test/guest/mkosi.conf b/modules/test/guest/mkosi.conf index 20c10771..6e528ca3 100644 --- a/modules/test/guest/mkosi.conf +++ b/modules/test/guest/mkosi.conf @@ -1,4 +1,5 @@ [Include] Include=./attestation-result Include=./attestation-workflow +Include=./key-derivation Include=./test-done diff --git a/modules/test/guest/test-done/mkosi.extra/usr/local/lib/systemd/system/test-done.service b/modules/test/guest/test-done/mkosi.extra/usr/local/lib/systemd/system/test-done.service index fb76fd98..392a0b70 100644 --- a/modules/test/guest/test-done/mkosi.extra/usr/local/lib/systemd/system/test-done.service +++ b/modules/test/guest/test-done/mkosi.extra/usr/local/lib/systemd/system/test-done.service @@ -2,8 +2,8 @@ Description=Barrier that triggers test services DefaultDependencies=no -Requires=attestation-result.service attestation-workflow.service -After=attestation-result.service attestation-workflow.service +Requires=attestation-result.service attestation-workflow.service key-derivation.service +After=attestation-result.service attestation-workflow.service key-derivation.service [Service] Type=oneshot From 9435e63c50977ade6717dd24fc2ab7000ce23f00 Mon Sep 17 00:00:00 2001 From: Mark Gentry Date: Tue, 12 May 2026 10:47:19 -0500 Subject: [PATCH 2/3] fix: put derived keys test first, before the other guest tests See the last paragraph for more on ordering the new keys test. See README.md in modules/test/guest/key-derivation for more about the derived keys test, in general. Old TCB components (eg, PSP FW, microcode) can cause some guest tests to fail. In general, I don't think this should be the case, but if the TCB components are very old, then maybe it makes sense. By putting the derived keys test first, this test's contributions to the certificates should be present regardless of how old the TCB components are. Note that preserving the derived keys test contributions could also be achieved by using Wants= instead of Requires= for the services corresponding to the tests that fail due to the old TCB components. --- .../usr/local/lib/systemd/system/key-derivation.service | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/modules/test/guest/key-derivation/mkosi.extra/usr/local/lib/systemd/system/key-derivation.service b/modules/test/guest/key-derivation/mkosi.extra/usr/local/lib/systemd/system/key-derivation.service index 97ac60c1..5c3b931d 100644 --- a/modules/test/guest/key-derivation/mkosi.extra/usr/local/lib/systemd/system/key-derivation.service +++ b/modules/test/guest/key-derivation/mkosi.extra/usr/local/lib/systemd/system/key-derivation.service @@ -2,6 +2,12 @@ Description=Run SNP Key Derivation Tests after boot DefaultDependencies=no After=system.target + +# Putting the key derivation test, which should never fail, first +# avoids the effect of other guest test services that fail. Doing +# it this way localizes the change. Other guest test services may fail, +# effectively, due to old PSP FW, microcode, etc. +Before=attestation-workflow.service Wants=system.target [Service] From 6774d1cc04ee823aedff87c4d6a542417a4e1a18 Mon Sep 17 00:00:00 2001 From: Mark Gentry Date: Thu, 14 May 2026 13:46:21 -0500 Subject: [PATCH 3/3] docs: address GitHub Copilot PR comments --- modules/test/guest/key-derivation/README.md | 102 +++++++++++++------- 1 file changed, 69 insertions(+), 33 deletions(-) diff --git a/modules/test/guest/key-derivation/README.md b/modules/test/guest/key-derivation/README.md index 5111b94c..062e81d2 100644 --- a/modules/test/guest/key-derivation/README.md +++ b/modules/test/guest/key-derivation/README.md @@ -8,7 +8,7 @@ The test suite validates the following key derivation properties: 1. **Determinism**: Same parameters produce the same key 2. **VMPL Isolation**: Different VMPL values produce different keys (cryptographic isolation) -3. **Root Key Difference**: VCK and VMRK produce different keys +3. **Root Key Difference**: VCEK and VMRK root key selections produce different keys 4. **Guest SVN Sensitivity**: Different guest SVN values produce different keys 5. **TCB Sensitivity**: Different TCB versions produce different keys 6. **Guest Field Select Sensitivity**: Different guest field select values produce different keys @@ -27,10 +27,21 @@ This prevents privilege escalation where compromised guest OS at VMPL1 attempts ### Root Key Selection Tests validate that different root keys produce different derived keys: -- **VCK (Versioned Chip Key)**: Symmetric key derived from CEK+TCB -- **VMRK (VM Root Key)**: VM-specific symmetric key for migration scenarios +- **VCEK (Versioned Chip Endorsement Key)**: Selected via `RootKeySelect=0` in `SNP_DERIVE_KEY` +- **VMRK (VM Root Key)**: VM-specific key for migration scenarios; selected via `RootKeySelect=1` -Note: Despite the name "vcek" in the CLI, the root key selection uses **VCK** (symmetric), not VCEK (asymmetric signing key). +AMD uses the name VCEK for two distinct roles: the asymmetric key that signs attestation reports, +and as the name for `RootKeySelect=0` in `SNP_DERIVE_KEY`. These are different uses of the same +underlying key material. The `snpguest` CLI follows AMD's naming directly. + +The output of `SNP_DERIVE_KEY` is a symmetric secret returned to the guest. Whether to call it +a "key" or a "seed" is somewhat in the eye of the beholder: the firmware does not use it +internally for encryption, decryption, or authentication — it is simply derived and handed to +the guest, which then uses it as a key for its own purposes. AMD and `snpguest` call it a derived +key, reflecting its intended use. + +VMRK is used in live migration to protect VM state across hosts, meaning the firmware may use it +internally for encryption or authentication — which places it more firmly in the "key" category. ## Test Architecture @@ -61,9 +72,15 @@ The tests can only run inside an SNP-enabled guest VM with: The tests run automatically via systemd service after boot. Manual execution: ```bash -# Run the test suite +# Run the test suite (pass/fail output only) /usr/local/lib/scripts/snpguest_key_derivation.py +# Run with verbose output (snpguest commands, key hex values, full attestation report) +/usr/local/lib/scripts/snpguest_key_derivation.py --debug + +# Derive a key for every valid GFS value (0x00-0x7f) and report which produce distinct keys +/usr/local/lib/scripts/snpguest_key_derivation.py --gfs-sweep + # View test results journalctl -u key-derivation.service @@ -76,31 +93,55 @@ cat /usr/local/lib/key_derivation_status Each test produces: - Status log in JSON format: `/usr/local/lib/key_derivation_status` - Derived keys stored in: `/usr/local/lib/key_derivation_service/` -- Console output with pass/fail status and key values +- Console output with pass/fail status (key hex values shown only with `--debug`) ### Expected Output -Successful test run: +What follows is an example of a successful test run (default, no `--debug`): ``` ====================================================================== -SNP Guest Key Derivation Test Suite +ATTESTATION REPORT (reference values for key derivation bounds) ====================================================================== + Guest SVN: 1 (upper bound for --guest_svn) + Current TCB: bl=0x07 tee=0x00 snp=0x0b mc=0x16 + Committed TCB: bl=0x07 tee=0x00 snp=0x0b mc=0x16 (upper bound per component for --tcb_version) + Reported TCB: bl=0x07 tee=0x00 snp=0x0b mc=0x16 + Launch TCB: bl=0x07 tee=0x00 snp=0x0b mc=0x16 ====================================================================== -TEST: Key Derivation Determinism +TEST: Determinism ====================================================================== ✓ PASS: Keys match (deterministic) - Key: 0x ====================================================================== -TEST: VMPL-Based Key Isolation +TEST: VMPL Isolation ====================================================================== ✓ PASS: VMPL0 and VMPL1 keys differ (proper isolation) - VMPL0 Key: 0x - VMPL1 Key: 0x -... +====================================================================== +TEST: Root Key Difference +====================================================================== +✓ PASS: VCEK and VMRK keys differ + +====================================================================== +TEST: Guest SVN Sensitivity +====================================================================== + Guest SVN upper bound: 1 + Testing 2 SVN values: [0, 1] +✓ PASS: All 2 SVN values produce distinct keys + +====================================================================== +TEST: TCB Sensitivity +====================================================================== + Committed TCB (upper bound per component): bl=0x07 tee=0x00 snp=0x0b mc=0x16 + Testing 5 TCB candidate(s) +✓ PASS: All 5 TCB values produce distinct keys + +====================================================================== +TEST: Guest Field Select Sensitivity +====================================================================== +✓ PASS: GFS=0x01 and GFS=0x02 keys differ ====================================================================== TEST SUMMARY @@ -117,6 +158,14 @@ Passed: 6/6 ✓ All key derivation tests passed! ``` +### N/A Cases + +Some tests may report `✓ PASS: N/A` rather than a full result: + +- **Guest SVN Sensitivity**: Reports N/A when the guest was launched without an ID block (`guest_svn=0` in the attestation report), or with an ID block that explicitly sets the Guest SVN value to zero. Either way, it leaves only one valid SVN value to test. +- **TCB Sensitivity**: Reports N/A when all committed TCB components are zero. +- **VMPL Isolation**: Reports N/A when VMPL1 key derivation fails (expected if not running at VMPL0 or VMPL1). + ## Implementation Notes ### Python vs Bash @@ -127,28 +176,15 @@ Unlike the attestation tests which use bash, this module uses Python for: - Easier maintenance and extension - Native JSON handling for status logs -### VMPL Constraints - -The VMPL isolation test may produce warnings if running at VMPL > 0, as the firmware enforces that derived keys can only be requested for VMPL values ≥ current VMPL. This is expected behavior and validates the security constraint. - -### Key Display +### Attestation Report at Startup -The tests use `snpguest display key` to read derived keys as hex strings for comparison. This avoids binary file comparison issues and provides human-readable output. +Before running tests, the script fetches an attestation report to extract the guest SVN and committed TCB values. These provide the valid upper bounds for the SVN sensitivity and TCB sensitivity tests respectively, avoiding firmware rejections from out-of-range parameter values. -## Integration with sev-certify +### VMPL Constraints -To include key-derivation tests in the guest build, update the parent `mkosi.conf`: +The VMPL isolation test may produce warnings if running at VMPL > 0, as the firmware enforces that derived keys can only be requested for VMPL values ≥ current VMPL. This is expected behavior and validates the security constraint. -```conf -[Include] -Include=./attestation-result -Include=./attestation-workflow -Include=./key-derivation # Add this line -Include=./test-done -``` +### Key Reading -## References +Derived keys are read directly from the output file bytes (`.read_bytes().hex()`). Key hex values are only printed when `--debug` is active. -- [CLAUDE.md](../../../../../CLAUDE.md) - VCK/VCEK naming clarification -- [SEV-SNP-ARCHITECTURE.md](../../../../../SEV-SNP-ARCHITECTURE.md) - VMPL isolation details -- [snpguest documentation](https://github.com/virtee/snpguest) - Key derivation API