|
10 | 10 | //! one built into `sev-snp-qvl`. |
11 | 11 |
|
12 | 12 | 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}; |
14 | 16 |
|
15 | 17 | /// Real SEV-SNP attestation captured from a dstack CVM (VersionedAttestation, SCALE V0). |
16 | 18 | const SEV_ATTESTATION_BIN: &[u8] = include_bytes!("sev_snp_attestation.bin"); |
@@ -104,3 +106,225 @@ fn verify_sev_snp_attestation_bin() { |
104 | 106 | ); |
105 | 107 | println!("os_image_hash: {}", hex::encode(&binding.os_image_hash)); |
106 | 108 | } |
| 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