Skip to content

Commit 719370a

Browse files
committed
test: AMD SEV-SNP forged-quote / tampered-input coverage
Adversarial negative tests for the SEV-SNP verification path: dstack-mr::sev (synthetic, deterministic): - forged hardware MEASUREMENT and HOST_DATA are rejected - every measured launch field (ovmf/kernel/initrd hashes, cmdline, hash-table offset, reset eip, section gpa, vcpus, vcpu_type, guest_features) is caught by the measurement-equality check - substituting a different MrConfigV3 (app/compose/instance id) breaks the HOST_DATA binding - an advertised top-level os_image_hash is ignored (derived value wins) - booting a different image cannot present an allow-listed image's inputs - missing sev_snp_measurement / mr_config fail closed - documents that rootfs_hash is os_image_hash-only (bound via the measured cmdline), so tampering it changes the derived os_image_hash rather than failing the measurement check dstack-attest (real fixture, offline): - flipping any signed report field (report_data/measurement/host_data) or the signature invalidates VCEK verification; zeroed/truncated reports rejected - wrong collateral (ASK-as-VCEK, malformed VCEK) rejected - forged measurement/host_data, tampered launch inputs, substituted mr_config and bogus advertised os_image_hash all handled correctly against real data Derive Debug on SevImageBinding for test ergonomics.
1 parent d80daeb commit 719370a

2 files changed

Lines changed: 482 additions & 1 deletion

File tree

dstack-attest/tests/sev_snp_verify.rs

Lines changed: 225 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
//! one built into `sev-snp-qvl`.
1111
1212
use dstack_attest::attestation::{AttestationQuote, VersionedAttestation};
13-
use sev_snp_qvl::{verify_amd_snp_attestation, AmdSnpAttestationInput};
13+
use dstack_mr::sev::verify_sev_launch;
14+
use dstack_types::{mr_config::MrConfigV3, KeyProviderKind};
15+
use sev_snp_qvl::{verify_amd_snp_attestation, AmdSnpAttestationInput, VerifiedAmdSnpReport};
1416

1517
/// Real SEV-SNP attestation captured from a dstack CVM (VersionedAttestation, SCALE V0).
1618
const SEV_ATTESTATION_BIN: &[u8] = include_bytes!("sev_snp_attestation.bin");
@@ -104,3 +106,225 @@ fn verify_sev_snp_attestation_bin() {
104106
);
105107
println!("os_image_hash: {}", hex::encode(&binding.os_image_hash));
106108
}
109+
110+
// ---------------------------------------------------------------------------
111+
// Forged / tampered quote coverage (all offline, using the real fixture).
112+
// ---------------------------------------------------------------------------
113+
114+
const OS_IMAGE_HASH: &str = "32b4767373ad7fa0f9c418925006194d5c3f5619529f309fe81156789fecd8bc";
115+
116+
fn decoded_attestation() -> dstack_attest::attestation::Attestation {
117+
let versioned =
118+
VersionedAttestation::from_scale(SEV_ATTESTATION_BIN).expect("decode VersionedAttestation");
119+
let VersionedAttestation::V0 { attestation } = versioned else {
120+
panic!("expected V0 attestation");
121+
};
122+
attestation
123+
}
124+
125+
fn fixture_report() -> Vec<u8> {
126+
let attestation = decoded_attestation();
127+
let AttestationQuote::DstackAmdSevSnp(quote) = &attestation.quote else {
128+
panic!("expected an AMD SEV-SNP quote");
129+
};
130+
quote.report.clone()
131+
}
132+
133+
fn fixture_config() -> String {
134+
decoded_attestation().config
135+
}
136+
137+
fn verified_fixture_report() -> VerifiedAmdSnpReport {
138+
let report = fixture_report();
139+
verify_amd_snp_attestation(&AmdSnpAttestationInput {
140+
report: &report,
141+
ask_pem: SEV_ASK_PEM,
142+
vcek_pem: SEV_VCEK_PEM,
143+
})
144+
.expect("verify SEV-SNP attestation offline")
145+
}
146+
147+
/// Rewrite one field inside the embedded `sev_snp_measurement` document.
148+
fn with_measurement_field(config: &str, f: impl FnOnce(&mut serde_json::Value)) -> String {
149+
let mut value: serde_json::Value = serde_json::from_str(config).expect("config json");
150+
let measurement_doc = value["sev_snp_measurement"]
151+
.as_str()
152+
.expect("sev_snp_measurement string")
153+
.to_string();
154+
let mut measurement: serde_json::Value =
155+
serde_json::from_str(&measurement_doc).expect("measurement json");
156+
f(&mut measurement);
157+
value["sev_snp_measurement"] =
158+
serde_json::Value::String(serde_json::to_string(&measurement).expect("reserialize"));
159+
value.to_string()
160+
}
161+
162+
/// Replace the embedded MrConfigV3 document with a different one.
163+
fn set_mr_config(config: &str, mr_config_doc: &str) -> String {
164+
let mut value: serde_json::Value = serde_json::from_str(config).expect("config json");
165+
value["mr_config"] = serde_json::Value::String(mr_config_doc.to_string());
166+
value.to_string()
167+
}
168+
169+
#[test]
170+
fn forged_report_bytes_fail_signature_verification() {
171+
let report = fixture_report();
172+
// Flip a byte in each signed field (and the signature itself); the VCEK
173+
// signature over the report must no longer verify.
174+
// SNP ATTESTATION_REPORT offsets: report_data 0x50, measurement 0x90,
175+
// host_data 0xC0, signature 0x2A0.
176+
for (name, offset) in [
177+
("report_data", 0x50usize),
178+
("measurement", 0x90),
179+
("host_data", 0xC0),
180+
("signature", 0x2A0),
181+
] {
182+
let mut tampered = report.clone();
183+
tampered[offset] ^= 0xff;
184+
let result = verify_amd_snp_attestation(&AmdSnpAttestationInput {
185+
report: &tampered,
186+
ask_pem: SEV_ASK_PEM,
187+
vcek_pem: SEV_VCEK_PEM,
188+
});
189+
assert!(
190+
result.is_err(),
191+
"tampering the {name} field must invalidate the report signature"
192+
);
193+
}
194+
195+
// A well-formed-length but zeroed report has no valid signature.
196+
let zeroed = vec![0u8; 1184];
197+
assert!(
198+
verify_amd_snp_attestation(&AmdSnpAttestationInput {
199+
report: &zeroed,
200+
ask_pem: SEV_ASK_PEM,
201+
vcek_pem: SEV_VCEK_PEM,
202+
})
203+
.is_err(),
204+
"a zeroed report must not verify"
205+
);
206+
207+
// A truncated report must be rejected, not parsed.
208+
assert!(
209+
verify_amd_snp_attestation(&AmdSnpAttestationInput {
210+
report: &report[..200],
211+
ask_pem: SEV_ASK_PEM,
212+
vcek_pem: SEV_VCEK_PEM,
213+
})
214+
.is_err(),
215+
"a truncated report must be rejected"
216+
);
217+
}
218+
219+
#[test]
220+
fn wrong_collateral_is_rejected() {
221+
let report = fixture_report();
222+
// The ASK presented as the VCEK leaf: the report signature won't verify
223+
// against the intermediate key.
224+
assert!(
225+
verify_amd_snp_attestation(&AmdSnpAttestationInput {
226+
report: &report,
227+
ask_pem: SEV_ASK_PEM,
228+
vcek_pem: SEV_ASK_PEM,
229+
})
230+
.is_err(),
231+
"using the ASK as the VCEK must be rejected"
232+
);
233+
234+
// Garbage VCEK PEM.
235+
let junk = b"-----BEGIN CERTIFICATE-----\nbm90IGEgY2VydA==\n-----END CERTIFICATE-----\n";
236+
assert!(
237+
verify_amd_snp_attestation(&AmdSnpAttestationInput {
238+
report: &report,
239+
ask_pem: SEV_ASK_PEM,
240+
vcek_pem: junk,
241+
})
242+
.is_err(),
243+
"a malformed VCEK must be rejected"
244+
);
245+
}
246+
247+
#[test]
248+
fn forged_launch_measurement_is_rejected() {
249+
let verified = verified_fixture_report();
250+
let config = fixture_config();
251+
let mut forged = verified.measurement;
252+
forged[0] ^= 0xff;
253+
let err = verify_sev_launch(&forged, &verified.host_data, &config)
254+
.expect_err("a measurement that disagrees with the launch inputs must reject");
255+
assert!(
256+
err.to_string().contains("amd sev-snp measurement mismatch"),
257+
"unexpected error: {err:?}"
258+
);
259+
}
260+
261+
#[test]
262+
fn forged_host_data_is_rejected() {
263+
let verified = verified_fixture_report();
264+
let config = fixture_config();
265+
let mut forged = verified.host_data;
266+
forged[0] ^= 0xff;
267+
let err = verify_sev_launch(&verified.measurement, &forged, &config)
268+
.expect_err("host_data that does not bind the mr_config must reject");
269+
assert!(
270+
err.to_string().contains("amd sev-snp host_data mismatch"),
271+
"unexpected error: {err:?}"
272+
);
273+
}
274+
275+
#[test]
276+
fn tampered_launch_inputs_break_os_image_binding() {
277+
// Swap in a different kernel hash in the advertised launch inputs: the
278+
// recomputed measurement no longer equals the hardware MEASUREMENT, so the
279+
// forged (allow-listed-looking) os_image_hash is never trusted.
280+
let verified = verified_fixture_report();
281+
let tampered = with_measurement_field(&fixture_config(), |m| {
282+
m["kernel_hash"] = serde_json::Value::String("00".repeat(32));
283+
});
284+
let err = verify_sev_launch(&verified.measurement, &verified.host_data, &tampered)
285+
.expect_err("tampered launch inputs must reject");
286+
assert!(
287+
err.to_string().contains("amd sev-snp measurement mismatch"),
288+
"unexpected error: {err:?}"
289+
);
290+
}
291+
292+
#[test]
293+
fn substituted_mr_config_breaks_host_data_binding() {
294+
// Present a well-formed but different-identity MrConfigV3 document. The
295+
// hardware HOST_DATA still binds the original document, so this is rejected.
296+
let verified = verified_fixture_report();
297+
let evil = MrConfigV3::new(
298+
vec![0xab; 20],
299+
vec![0xcd; 32],
300+
KeyProviderKind::None,
301+
Vec::new(),
302+
vec![0xef; 20],
303+
);
304+
let tampered = set_mr_config(&fixture_config(), &evil.to_canonical_json());
305+
let err = verify_sev_launch(&verified.measurement, &verified.host_data, &tampered)
306+
.expect_err("a substituted mr_config must reject");
307+
assert!(
308+
err.to_string().contains("amd sev-snp host_data mismatch"),
309+
"unexpected error: {err:?}"
310+
);
311+
}
312+
313+
#[test]
314+
fn advertised_os_image_hash_is_ignored() {
315+
// A forged top-level os_image_hash is ignored; the authoritative value is
316+
// derived from the measurement-bound launch inputs.
317+
let verified = verified_fixture_report();
318+
let mut value: serde_json::Value =
319+
serde_json::from_str(&fixture_config()).expect("config json");
320+
value["os_image_hash"] = serde_json::Value::String("de".repeat(32));
321+
let tampered = value.to_string();
322+
323+
let binding = verify_sev_launch(&verified.measurement, &verified.host_data, &tampered)
324+
.expect("a bogus advertised os_image_hash is ignored, not fatal");
325+
assert_eq!(
326+
hex::encode(&binding.os_image_hash),
327+
OS_IMAGE_HASH,
328+
"derived os_image_hash must win over the advertised one"
329+
);
330+
}

0 commit comments

Comments
 (0)