diff --git a/dstack/ra-tls/Cargo.toml b/dstack/ra-tls/Cargo.toml index fbc1b9708..9f2d9fda9 100644 --- a/dstack/ra-tls/Cargo.toml +++ b/dstack/ra-tls/Cargo.toml @@ -49,4 +49,5 @@ rmp-serde.workspace = true quote = ["dstack-attest/quote"] [dev-dependencies] -tokio = { workspace = true, features = ["macros", "rt"] } +tokio = { workspace = true, features = ["full"] } +mock-attestation = { path = "../crates/mock-attestation" } diff --git a/dstack/ra-tls/src/attestation.rs b/dstack/ra-tls/src/attestation.rs index 01b15afed..217a36cf0 100644 --- a/dstack/ra-tls/src/attestation.rs +++ b/dstack/ra-tls/src/attestation.rs @@ -102,6 +102,29 @@ async fn verify_cert( .verify_with_ra_pubkey(&public_key_der, verifier) .await .context("RA-TLS attestation verification failed")?; + if app_id.is_some() || app_info.is_some() { + let attested = attestation + .decode_app_info(false) + .context("certificate identity extensions require attested app info")?; + if let Some(extension) = &app_id { + if extension != &attested.app_id { + bail!("certificate app-id extension does not match attested app id"); + } + } + if let Some(extension) = &app_info { + let matches = extension.app_id == attested.app_id + && extension.compose_hash == attested.compose_hash + && extension.instance_id == attested.instance_id + && extension.device_id == attested.device_id + && extension.mr_system == attested.mr_system + && extension.mr_aggregated == attested.mr_aggregated + && extension.os_image_hash == attested.os_image_hash + && extension.key_provider_info == attested.key_provider_info; + if !matches { + bail!("certificate app-info extension does not match attested app info"); + } + } + } Ok(VerifiedRaTlsCert { public_key_der, attestation, @@ -158,6 +181,252 @@ mod tests { .into_versioned() } + #[tokio::test] + async fn ra_certificate_profile_quote_key_and_app_mutation_matrix() { + use std::{sync::Arc, time::Duration}; + + use cc_eventlog::{EventLogVersion, RuntimeEvent}; + use mock_attestation::server::{serve_listener, MockCollateralState}; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let pccs = format!("http://{address}"); + let state = Arc::new(MockCollateralState::from_seed([0x74; 32], &pccs).unwrap()); + let server = tokio::spawn(serve_listener(listener, state.clone())); + let verifier = AttestationVerifier::new_with_tdx_root( + Some(&dstack_types::CollateralUrls { + pccs: Some(pccs), + ..Default::default() + }), + state.tdx.root_ca_pem().as_bytes(), + ) + .unwrap(); + + let key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap(); + let report_data = QuoteContentType::RaTlsCert.to_report_data(&key.public_key_der()); + let events = vec![ + RuntimeEvent::new("app-id".into(), vec![0x11; 20], EventLogVersion::V1), + RuntimeEvent::new("compose-hash".into(), vec![0x22; 32], EventLogVersion::V1), + RuntimeEvent::new("instance-id".into(), vec![0x33; 20], EventLogVersion::V1), + RuntimeEvent::new( + "key-provider".into(), + b"fixture-provider".to_vec(), + EventLogVersion::V1, + ), + ]; + let replayed = cc_eventlog::replay_events::(&events, None); + let mut rtmrs = [[0u8; 48]; 4]; + rtmrs[3].copy_from_slice(&replayed); + let evidence = state.tdx.attest_with_rtmrs(report_data, rtmrs).unwrap(); + let attestation = Attestation { + quote: AttestationQuote::DstackTdx(TdxQuote { + quote: evidence.quote, + event_log: events.iter().cloned().map(Into::into).collect(), + }), + runtime_events: events, + report_data, + config: format!(r#"{{"os_image_hash":"{}"}}"#, "44".repeat(32)), + report: (), + } + .into_versioned(); + let verified_attestation = attestation + .clone() + .into_v1() + .verify(&verifier) + .await + .unwrap(); + let app_info = verified_attestation.decode_app_info(false).unwrap(); + let alt_names = vec!["guest.example".to_string()]; + let cert = CertRequest::builder() + .key(&key) + .subject("guest.example") + .alt_names(&alt_names) + .usage_server_auth(true) + .app_id(&app_info.app_id) + .app_info(&app_info) + .attestation(&attestation) + .build() + .self_signed() + .unwrap(); + let valid = verify_der(cert.der().as_ref(), &verifier).await.unwrap(); + assert_eq!(valid.app_id.as_deref(), Some(app_info.app_id.as_slice())); + + let mut changed_app_info = app_info.clone(); + changed_app_info.os_image_hash[0] ^= 1; + let cert = CertRequest::builder() + .key(&key) + .subject("guest.example") + .alt_names(&alt_names) + .usage_server_auth(true) + .app_id(&app_info.app_id) + .app_info(&changed_app_info) + .attestation(&attestation) + .build() + .self_signed() + .unwrap(); + let error = verify_der(cert.der().as_ref(), &verifier) + .await + .err() + .unwrap(); + assert!(format!("{error:#}").contains("app-info extension does not match")); + + let mut changed_app_id = app_info.app_id.clone(); + changed_app_id[0] ^= 1; + let cert = CertRequest::builder() + .key(&key) + .subject("guest.example") + .alt_names(&alt_names) + .usage_server_auth(true) + .app_id(&changed_app_id) + .app_info(&app_info) + .attestation(&attestation) + .build() + .self_signed() + .unwrap(); + let error = verify_der(cert.der().as_ref(), &verifier) + .await + .err() + .unwrap(); + assert!(format!("{error:#}").contains("app-id extension does not match")); + + let mut changed_quote = attestation.clone(); + let VersionedAttestation::V0 { + attestation: changed, + } = &mut changed_quote + else { + unreachable!("V1 runtime events must use the legacy-compatible container") + }; + let AttestationQuote::DstackTdx(tdx_quote) = &mut changed.quote else { + unreachable!() + }; + tdx_quote.quote[100] ^= 1; + let cert = CertRequest::builder() + .key(&key) + .subject("guest.example") + .alt_names(&alt_names) + .usage_server_auth(true) + .attestation(&changed_quote) + .build() + .self_signed() + .unwrap(); + assert!(verify_der(cert.der().as_ref(), &verifier).await.is_err()); + + let wrong_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap(); + let cert = CertRequest::builder() + .key(&wrong_key) + .subject("guest.example") + .alt_names(&alt_names) + .usage_server_auth(true) + .attestation(&attestation) + .build() + .self_signed() + .unwrap(); + assert!(format!( + "{:#}", + verify_der(cert.der().as_ref(), &verifier) + .await + .err() + .unwrap() + ) + .contains("report data mismatch")); + + let no_san = CertRequest::builder() + .key(&key) + .subject("guest.example") + .usage_server_auth(true) + .attestation(&attestation) + .build() + .self_signed() + .unwrap(); + assert!(format!( + "{:#}", + verify_der(no_san.der().as_ref(), &verifier) + .await + .err() + .unwrap() + ) + .contains("SAN extension missing")); + + let no_eku = CertRequest::builder() + .key(&key) + .subject("guest.example") + .alt_names(&alt_names) + .attestation(&attestation) + .build() + .self_signed() + .unwrap(); + assert!(format!( + "{:#}", + verify_der(no_eku.der().as_ref(), &verifier) + .await + .err() + .unwrap() + ) + .contains("extended key usage extension missing")); + + let profile_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap(); + let mut params = rcgen::CertificateParams::new(alt_names.clone()).unwrap(); + params.key_usages = vec![rcgen::KeyUsagePurpose::KeyEncipherment]; + params + .extended_key_usages + .push(rcgen::ExtendedKeyUsagePurpose::ServerAuth); + let bad_usage = params.self_signed(&profile_key).unwrap(); + let (_, parsed) = x509_parser::parse_x509_certificate(bad_usage.der()).unwrap(); + assert!(verify_certificate_profile(&parsed) + .unwrap_err() + .to_string() + .contains("does not permit digital signatures")); + + let now = std::time::SystemTime::now(); + let expired = CertRequest::builder() + .key(&key) + .subject("guest.example") + .alt_names(&alt_names) + .usage_server_auth(true) + .not_before(now - Duration::from_secs(2 * 86400)) + .not_after(now - Duration::from_secs(86400)) + .attestation(&attestation) + .build() + .self_signed() + .unwrap(); + assert!(format!( + "{:#}", + verify_der(expired.der().as_ref(), &verifier) + .await + .err() + .unwrap() + ) + .contains("outside its validity period")); + + let mut bad_signature = valid_cert_bytes(&key, &alt_names, &attestation); + let last = bad_signature.len() - 1; + bad_signature[last] ^= 1; + assert!(format!( + "{:#}", + verify_der(&bad_signature, &verifier).await.err().unwrap() + ) + .contains("self-signature verification failed")); + server.abort(); + } + + fn valid_cert_bytes( + key: &KeyPair, + alt_names: &[String], + attestation: &VersionedAttestation, + ) -> Vec { + CertRequest::builder() + .key(key) + .subject("guest.example") + .alt_names(alt_names) + .usage_server_auth(true) + .attestation(attestation) + .build() + .self_signed() + .unwrap() + .der() + .to_vec() + } + #[tokio::test] async fn verify_der_rejects_missing_attestation_extension() { let key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap();