Skip to content

Commit 114bc93

Browse files
committed
feat(attest): accept several collateral endpoints instead of one
Attestation verification depends on collateral only the vendor publishes, and until now each source was configured as exactly one URL. AMD KDS is the sharper case: a single global endpoint with no official mirror, rate limited to roughly one identical request per ten seconds, and unreachable for hours at a time -- during which nothing on the host can verify an SEV-SNP quote, so no AMD guest can obtain keys. `pccs` and `amd_kds` now take either a string or a list. Endpoints are tried in order and the first answer wins. Existing configs and already-serialized `SysConfig` blobs parse unchanged, and a one-element list serializes back as a bare string, because guests hash the sys-config they were given rather than what a newer binary would have written. Failing over relaxes nothing. Collateral is vendor-signed and checked against roots compiled into the binary, so an endpoint can be slow, stale or absent but cannot forge an answer; which endpoint replied has no bearing on whether the signature checks pass. That is what makes a cache or mirror a legitimate entry in the list rather than a hole in the trust model. The two clients fail over on different rules, because they can see different things: - AMD KDS is in-tree, so it classifies. Transport failures, 408, 429 and 5xx say something about the endpoint and move on; a 404 is an answer about the chip that every mirror repeats, so it surfaces as-is rather than being retried into a pile of identical errors. - PCCS goes through `dcap-qvl`, which returns `anyhow::Error` with no status to classify on. It fails over on any error. Matching on error strings to do better would be worse than the coarse rule it replaced. Tests drive real sockets rather than asserting on the shape of the code: a 429 and a refused connection each hand off, a 404 stops with the second endpoint never contacted, and an all-fail run names every endpoint it tried.
1 parent 1a90888 commit 114bc93

11 files changed

Lines changed: 645 additions & 100 deletions

File tree

dstack/dstack-attest/src/attestation.rs

Lines changed: 6 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@ pub const TDX_QUOTE_REPORT_DATA_RANGE: std::ops::Range<usize> = 568..632;
1111

1212
use std::{borrow::Cow, time::SystemTime};
1313

14+
use crate::collateral::{amd_kds_client, PccsClient};
1415
use anyhow::{anyhow, bail, Context, Result};
1516
use cc_eventlog::{EventLogVersion, RuntimeEvent, TdxEvent};
1617
use dcap_qvl::{
17-
collateral::CollateralClient,
1818
quote::{EnclaveReport, Quote, Report, TDReport10, TDReport15},
1919
verify::VerifiedReport as TdxVerifiedReport,
2020
};
@@ -57,7 +57,7 @@ pub struct AttestationVerifierConfig {
5757

5858
pub struct AttestationVerifier {
5959
tdx: dcap_qvl::verify::QuoteVerifier,
60-
tdx_collateral: CollateralClient,
60+
tdx_collateral: PccsClient,
6161
gcp_tpm: tpm_qvl::QuoteVerifier,
6262
aws_nitro_enclave: nsm_qvl::QuoteVerifier,
6363
aws_nitro_tpm: nsm_qvl::QuoteVerifier,
@@ -130,51 +130,27 @@ impl AttestationVerifier {
130130
sev_snp = sev_snp.with_root(product, root);
131131
}
132132
}
133-
let pccs = config
134-
.urls
135-
.pccs
136-
.as_deref()
137-
.filter(|v| !v.trim().is_empty())
138-
.unwrap_or(dcap_qvl::collateral::PHALA_PCCS_URL);
139-
let amd_kds = config
140-
.urls
141-
.amd_kds
142-
.as_deref()
143-
.filter(|v| !v.trim().is_empty())
144-
.unwrap_or(sev_snp_qvl::AMD_KDS_DEFAULT_BASE_URL);
145133
Ok(Self {
146134
tdx,
147-
tdx_collateral: CollateralClient::with_default_http(pccs)?,
135+
tdx_collateral: PccsClient::new(&config.urls.pccs)?,
148136
gcp_tpm,
149137
aws_nitro_enclave: nsm(aws_nitro_enclave.as_deref(), "AWS Nitro Enclave")?,
150138
aws_nitro_tpm: nsm(aws_nitro_tpm.as_deref(), "AWS NitroTPM")?,
151139
sev_snp,
152-
amd_kds: AmdKdsClient::with_base_url(amd_kds)?,
140+
amd_kds: amd_kds_client(&config.urls.amd_kds)?,
153141
})
154142
}
155143

156144
pub fn new_prod(collateral_urls: Option<&CollateralUrls>) -> Result<Self> {
157145
let collateral_urls = collateral_urls.cloned().unwrap_or_default();
158146
Ok(Self {
159147
tdx: dcap_qvl::verify::QuoteVerifier::new_prod(),
160-
tdx_collateral: CollateralClient::with_default_http(
161-
collateral_urls
162-
.pccs
163-
.as_deref()
164-
.filter(|url| !url.trim().is_empty())
165-
.unwrap_or(dcap_qvl::collateral::PHALA_PCCS_URL),
166-
)?,
148+
tdx_collateral: PccsClient::new(&collateral_urls.pccs)?,
167149
gcp_tpm: tpm_qvl::QuoteVerifier::new_prod(Platform::Gcp)?,
168150
aws_nitro_enclave: nsm_qvl::QuoteVerifier::new_prod(),
169151
aws_nitro_tpm: nsm_qvl::QuoteVerifier::new_prod(),
170152
sev_snp: sev_snp_qvl::QuoteVerifier::new_prod(),
171-
amd_kds: AmdKdsClient::with_base_url(
172-
collateral_urls
173-
.amd_kds
174-
.as_deref()
175-
.filter(|url| !url.trim().is_empty())
176-
.unwrap_or(sev_snp_qvl::AMD_KDS_DEFAULT_BASE_URL),
177-
)?,
153+
amd_kds: amd_kds_client(&collateral_urls.amd_kds)?,
178154
})
179155
}
180156

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// SPDX-FileCopyrightText: © 2024-2025 Phala Network <dstack@phala.network>
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
5+
//! Failover across interchangeable collateral endpoints.
6+
//!
7+
//! Attestation verification depends on collateral that only the vendor
8+
//! publishes: Intel's PCCS and AMD's KDS. Both were single points of failure
9+
//! for verification until now -- one URL each, and no answer meant no
10+
//! verification. AMD KDS in particular is a single global endpoint with no
11+
//! official mirror, and it has been unreachable for hours at a time.
12+
//!
13+
//! Nothing here relaxes verification. Collateral is signed by the vendor and
14+
//! checked against roots compiled into the binary, so an endpoint can serve a
15+
//! stale or absent answer but cannot serve a forged one; which endpoint
16+
//! answered has no bearing on whether the signature checks pass.
17+
18+
use anyhow::{bail, Context, Result};
19+
use dcap_qvl::{
20+
collateral::{CollateralClient, PHALA_PCCS_URL},
21+
verify::VerifiedReport,
22+
QuoteCollateralV3,
23+
};
24+
use dstack_types::UrlList;
25+
use sev_snp_qvl::{AmdKdsClient, AMD_KDS_DEFAULT_BASE_URL};
26+
27+
/// Build an AMD KDS client over the configured endpoints, or the vendor
28+
/// default when none are configured.
29+
pub(crate) fn amd_kds_client(urls: &UrlList) -> Result<AmdKdsClient> {
30+
if urls.is_empty() {
31+
return AmdKdsClient::with_base_url(AMD_KDS_DEFAULT_BASE_URL);
32+
}
33+
AmdKdsClient::with_base_urls(urls.as_slice())
34+
}
35+
36+
/// Intel PCCS access over one or more interchangeable endpoints.
37+
///
38+
/// Endpoints are tried in order and the first success wins.
39+
///
40+
/// Unlike the AMD KDS client, this fails over on *any* error rather than only
41+
/// on ones that look transient. `dcap-qvl` returns `anyhow::Error`, so there is
42+
/// no status code to classify on, and inventing a classifier by matching error
43+
/// strings would be worse than the thing it replaces. The cost of the coarser
44+
/// rule is bounded: a genuinely absent FMSPC costs one extra request per
45+
/// configured endpoint, and configuring an endpoint list is opt-in.
46+
#[derive(Clone)]
47+
pub struct PccsClient {
48+
clients: Vec<(String, CollateralClient)>,
49+
}
50+
51+
impl PccsClient {
52+
pub fn new(urls: &UrlList) -> Result<Self> {
53+
let urls: Vec<&str> = if urls.is_empty() {
54+
vec![PHALA_PCCS_URL]
55+
} else {
56+
urls.as_slice().iter().map(String::as_str).collect()
57+
};
58+
let clients = urls
59+
.into_iter()
60+
.map(|url| {
61+
CollateralClient::with_default_http(url)
62+
.with_context(|| format!("failed to create PCCS client for {url}"))
63+
.map(|client| (url.to_string(), client))
64+
})
65+
.collect::<Result<Vec<_>>>()?;
66+
if clients.is_empty() {
67+
bail!("PCCS endpoint list is empty");
68+
}
69+
Ok(Self { clients })
70+
}
71+
72+
pub async fn fetch(&self, quote: &[u8]) -> Result<QuoteCollateralV3> {
73+
self.failover("fetch", |client| client.fetch(quote)).await
74+
}
75+
76+
pub async fn fetch_and_verify(&self, quote: &[u8]) -> Result<VerifiedReport> {
77+
self.failover("fetch_and_verify", |client| client.fetch_and_verify(quote))
78+
.await
79+
}
80+
81+
async fn failover<'a, T, F, Fut>(&'a self, label: &str, mut call: F) -> Result<T>
82+
where
83+
F: FnMut(&'a CollateralClient) -> Fut,
84+
Fut: std::future::Future<Output = Result<T>>,
85+
{
86+
let mut errors: Vec<String> = Vec::new();
87+
for (url, client) in &self.clients {
88+
match call(client).await {
89+
Ok(value) => return Ok(value),
90+
Err(err) => errors.push(format!("{url}: {err:#}")),
91+
}
92+
}
93+
match errors.len() {
94+
0 => bail!("PCCS {label} had no endpoint to try"),
95+
1 => bail!("PCCS {label} failed: {}", errors[0]),
96+
n => bail!(
97+
"PCCS {label} failed on all {n} endpoints: {}",
98+
errors.join("; ")
99+
),
100+
}
101+
}
102+
}

dstack/dstack-attest/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ pub mod amd_sev_snp;
1515
pub mod attestation;
1616
#[cfg(feature = "quote")]
1717
mod aws_nitro_tpm;
18+
pub mod collateral;
1819
#[cfg(feature = "quote")]
1920
mod sev_snp;
2021
pub mod trust_anchors;

dstack/dstack-types/src/lib.rs

Lines changed: 185 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1238,20 +1238,114 @@ pub struct GatewayClusterConfig {
12381238
pub urls: Vec<String>,
12391239
}
12401240

1241+
/// One or more interchangeable endpoints for the same collateral service.
1242+
///
1243+
/// Deserializes from either a bare string or a list, so every config file and
1244+
/// every already-serialized `SysConfig` written by an older host keeps parsing
1245+
/// unchanged. Serializes back as a bare string when there is exactly one entry,
1246+
/// for the same reason.
1247+
///
1248+
/// Order is meaningful: callers try entries front to back and keep the first
1249+
/// answer. Blank entries are dropped on the way in, because a config that says
1250+
/// `pccs = ["", "https://..."]` means the second one.
1251+
#[derive(Debug, Clone, Default, PartialEq, Eq)]
1252+
pub struct UrlList(Vec<String>);
1253+
1254+
impl UrlList {
1255+
pub fn new(urls: impl IntoIterator<Item = impl Into<String>>) -> Self {
1256+
Self(
1257+
urls.into_iter()
1258+
.map(Into::into)
1259+
.map(|url| url.trim().to_string())
1260+
.filter(|url| !url.is_empty())
1261+
.collect(),
1262+
)
1263+
}
1264+
1265+
pub fn is_empty(&self) -> bool {
1266+
self.0.is_empty()
1267+
}
1268+
1269+
pub fn len(&self) -> usize {
1270+
self.0.len()
1271+
}
1272+
1273+
/// The endpoints in the order they should be tried.
1274+
pub fn as_slice(&self) -> &[String] {
1275+
&self.0
1276+
}
1277+
1278+
pub fn iter(&self) -> std::slice::Iter<'_, String> {
1279+
self.0.iter()
1280+
}
1281+
1282+
/// The endpoint a single-URL caller should use. `None` when the list is
1283+
/// empty, which callers read as "use the platform default".
1284+
pub fn first(&self) -> Option<&str> {
1285+
self.0.first().map(String::as_str)
1286+
}
1287+
}
1288+
1289+
impl From<String> for UrlList {
1290+
fn from(url: String) -> Self {
1291+
Self::new([url])
1292+
}
1293+
}
1294+
1295+
impl From<&str> for UrlList {
1296+
fn from(url: &str) -> Self {
1297+
Self::new([url])
1298+
}
1299+
}
1300+
1301+
impl From<Vec<String>> for UrlList {
1302+
fn from(urls: Vec<String>) -> Self {
1303+
Self::new(urls)
1304+
}
1305+
}
1306+
1307+
impl Serialize for UrlList {
1308+
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1309+
match self.0.as_slice() {
1310+
[single] => serializer.serialize_str(single),
1311+
many => many.serialize(serializer),
1312+
}
1313+
}
1314+
}
1315+
1316+
impl<'de> Deserialize<'de> for UrlList {
1317+
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1318+
#[derive(Deserialize)]
1319+
#[serde(untagged)]
1320+
enum OneOrMany {
1321+
One(String),
1322+
Many(Vec<String>),
1323+
}
1324+
Ok(match OneOrMany::deserialize(deserializer)? {
1325+
OneOrMany::One(url) => Self::new([url]),
1326+
OneOrMany::Many(urls) => Self::new(urls),
1327+
})
1328+
}
1329+
}
1330+
12411331
#[derive(Deserialize, Serialize, Debug, Clone, Default, PartialEq, Eq)]
12421332
#[serde(deny_unknown_fields)]
12431333
pub struct CollateralUrls {
1244-
#[serde(default, skip_serializing_if = "Option::is_none")]
1245-
pub pccs: Option<String>,
1246-
#[serde(default, skip_serializing_if = "Option::is_none")]
1247-
pub amd_kds: Option<String>,
1334+
/// Intel PCCS endpoints. Empty means the built-in default.
1335+
#[serde(default, skip_serializing_if = "UrlList::is_empty")]
1336+
pub pccs: UrlList,
1337+
/// AMD KDS endpoints, or mirrors of it. Empty means the built-in default.
1338+
#[serde(default, skip_serializing_if = "UrlList::is_empty")]
1339+
pub amd_kds: UrlList,
12481340
}
12491341

12501342
impl SysConfig {
12511343
pub fn collateral_urls(&self) -> CollateralUrls {
12521344
let mut urls = self.collateral_urls.clone().unwrap_or_default();
1253-
if urls.pccs.is_none() {
1254-
urls.pccs.clone_from(&self.legacy_pccs_url);
1345+
if urls.pccs.is_empty() {
1346+
if let Some(legacy) = &self.legacy_pccs_url {
1347+
urls.pccs = UrlList::from(legacy.clone());
1348+
}
12551349
}
12561350
urls
12571351
}
@@ -2682,3 +2776,88 @@ mod appcompose_sdk_parity {
26822776
);
26832777
}
26842778
}
2779+
2780+
#[cfg(test)]
2781+
mod collateral_url_tests {
2782+
use super::*;
2783+
2784+
#[test]
2785+
fn a_bare_string_and_a_list_both_parse() {
2786+
let single: CollateralUrls =
2787+
serde_json::from_str(r#"{"pccs":"https://pccs.example"}"#).unwrap();
2788+
assert_eq!(single.pccs.as_slice(), ["https://pccs.example"]);
2789+
2790+
let many: CollateralUrls =
2791+
serde_json::from_str(r#"{"amd_kds":["https://a/vcek/v1","https://b/vcek/v1"]}"#)
2792+
.unwrap();
2793+
assert_eq!(
2794+
many.amd_kds.as_slice(),
2795+
["https://a/vcek/v1", "https://b/vcek/v1"]
2796+
);
2797+
}
2798+
2799+
/// A `SysConfig` written by a host that predates lists must still round
2800+
/// trip through a new binary unchanged, because the guest hashes what it
2801+
/// is given rather than what it would have written itself.
2802+
#[test]
2803+
fn a_single_url_serializes_back_as_a_bare_string() {
2804+
let urls = CollateralUrls {
2805+
pccs: UrlList::from("https://pccs.example"),
2806+
amd_kds: UrlList::default(),
2807+
};
2808+
assert_eq!(
2809+
serde_json::to_string(&urls).unwrap(),
2810+
r#"{"pccs":"https://pccs.example"}"#
2811+
);
2812+
}
2813+
2814+
#[test]
2815+
fn several_urls_serialize_as_a_list() {
2816+
let urls = CollateralUrls {
2817+
pccs: UrlList::default(),
2818+
amd_kds: UrlList::new(["https://a", "https://b"]),
2819+
};
2820+
assert_eq!(
2821+
serde_json::to_string(&urls).unwrap(),
2822+
r#"{"amd_kds":["https://a","https://b"]}"#
2823+
);
2824+
}
2825+
2826+
#[test]
2827+
fn blank_entries_are_dropped_on_the_way_in() {
2828+
let urls: CollateralUrls =
2829+
serde_json::from_str(r#"{"pccs":["", " ", "https://real"]}"#).unwrap();
2830+
assert_eq!(urls.pccs.as_slice(), ["https://real"]);
2831+
2832+
let blank: CollateralUrls = serde_json::from_str(r#"{"pccs":" "}"#).unwrap();
2833+
assert!(
2834+
blank.pccs.is_empty(),
2835+
"a blank string means 'unset', not 'an endpoint named blank'"
2836+
);
2837+
}
2838+
2839+
#[test]
2840+
fn the_legacy_pccs_url_field_still_wins_when_no_list_is_configured() {
2841+
let sys_config: SysConfig = serde_json::from_str(
2842+
r#"{"kms_urls":[],"gateway_urls":[],"pccs_url":"https://legacy.example","vm_config":"{}"}"#,
2843+
)
2844+
.unwrap();
2845+
assert_eq!(
2846+
sys_config.collateral_urls().pccs.as_slice(),
2847+
["https://legacy.example"]
2848+
);
2849+
}
2850+
2851+
#[test]
2852+
fn an_explicit_list_takes_precedence_over_the_legacy_field() {
2853+
let sys_config: SysConfig = serde_json::from_str(
2854+
r#"{"kms_urls":[],"gateway_urls":[],"pccs_url":"https://legacy.example",
2855+
"collateral_urls":{"pccs":["https://new.example"]},"vm_config":"{}"}"#,
2856+
)
2857+
.unwrap();
2858+
assert_eq!(
2859+
sys_config.collateral_urls().pccs.as_slice(),
2860+
["https://new.example"]
2861+
);
2862+
}
2863+
}

0 commit comments

Comments
 (0)