Skip to main content

a3s_box_runtime/tee/
certs.rs

1//! AMD certificate chain fetching and caching.
2//!
3//! Fetches VCEK, ASK, and ARK certificates from the AMD Key Distribution
4//! Service (KDS) at `kds.amd.com`. Certificates are cached locally to
5//! avoid repeated network requests.
6
7use a3s_box_core::error::{BoxError, Result};
8use std::path::PathBuf;
9
10use super::attestation::{CertificateChain, TcbVersion};
11
12/// AMD KDS base URL for SEV-SNP certificates.
13const AMD_KDS_BASE_URL: &str = "https://kds.amd.com";
14
15/// AMD KDS VCEK endpoint path.
16const AMD_KDS_VCEK_PATH: &str = "vcek/v1";
17
18/// AMD product name for Milan (3rd gen EPYC).
19const PRODUCT_MILAN: &str = "Milan";
20
21/// AMD product name for Genoa (4th gen EPYC).
22const PRODUCT_GENOA: &str = "Genoa";
23
24/// Client for fetching certificates from AMD KDS.
25pub struct AmdKdsClient {
26    /// HTTP client for KDS requests.
27    http: reqwest::Client,
28    /// Local cache directory for certificates.
29    cache_dir: Option<PathBuf>,
30}
31
32impl AmdKdsClient {
33    /// Create a new AMD KDS client.
34    ///
35    /// # Arguments
36    /// * `cache_dir` - Optional directory for caching certificates locally.
37    ///   If `None`, certificates are fetched on every request.
38    pub fn new(cache_dir: Option<PathBuf>) -> Self {
39        Self {
40            http: reqwest::Client::builder()
41                .no_proxy()
42                .build()
43                .expect("failed to build AMD KDS HTTP client"),
44            cache_dir,
45        }
46    }
47
48    /// Fetch the complete certificate chain for verifying an SNP report.
49    ///
50    /// Tries the local cache first, then falls back to AMD KDS.
51    ///
52    /// # Arguments
53    /// * `chip_id` - Hex-encoded chip ID from the SNP report (128 hex chars)
54    /// * `tcb` - TCB version from the SNP report
55    /// * `product` - CPU product name ("Milan" or "Genoa")
56    pub async fn fetch_cert_chain(
57        &self,
58        chip_id: &str,
59        tcb: &TcbVersion,
60        product: &str,
61    ) -> Result<CertificateChain> {
62        // Try cache first
63        if let Some(cached) = self.load_from_cache(chip_id, tcb).await {
64            tracing::debug!(chip_id = &chip_id[..16], "Using cached certificate chain");
65            return Ok(cached);
66        }
67
68        // Fetch VCEK certificate
69        let vcek = self.fetch_vcek(chip_id, tcb, product).await?;
70
71        // Fetch ASK + ARK certificate chain
72        let (ask, ark) = self.fetch_ask_ark(product).await?;
73
74        let chain = CertificateChain { vcek, ask, ark };
75
76        // Cache for future use
77        self.save_to_cache(chip_id, tcb, &chain).await;
78
79        Ok(chain)
80    }
81
82    /// Fetch the VCEK certificate from AMD KDS.
83    ///
84    /// URL format: `https://kds.amd.com/vcek/v1/{product}/{chip_id}?blSPL={bl}&teeSPL={tee}&snpSPL={snp}&ucodeSPL={ucode}`
85    async fn fetch_vcek(&self, chip_id: &str, tcb: &TcbVersion, product: &str) -> Result<Vec<u8>> {
86        let url = format!(
87            "{}/{}/{}/{}?blSPL={}&teeSPL={}&snpSPL={}&ucodeSPL={}",
88            AMD_KDS_BASE_URL,
89            AMD_KDS_VCEK_PATH,
90            product,
91            chip_id,
92            tcb.boot_loader,
93            tcb.tee,
94            tcb.snp,
95            tcb.microcode,
96        );
97
98        tracing::debug!(url = %url, "Fetching VCEK from AMD KDS");
99
100        let response = self
101            .http
102            .get(&url)
103            .header("Accept", "application/x-pem-file")
104            .send()
105            .await
106            .map_err(|e| {
107                BoxError::AttestationError(format!("Failed to fetch VCEK from AMD KDS: {}", e))
108            })?;
109
110        if !response.status().is_success() {
111            return Err(BoxError::AttestationError(format!(
112                "AMD KDS returned {} for VCEK request",
113                response.status()
114            )));
115        }
116
117        response
118            .bytes()
119            .await
120            .map(|b| b.to_vec())
121            .map_err(|e| BoxError::AttestationError(format!("Failed to read VCEK response: {}", e)))
122    }
123
124    /// Fetch the ASK and ARK certificates from AMD KDS.
125    ///
126    /// URL: `https://kds.amd.com/vcek/v1/{product}/cert_chain`
127    /// Returns a PEM bundle containing both ASK and ARK.
128    async fn fetch_ask_ark(&self, product: &str) -> Result<(Vec<u8>, Vec<u8>)> {
129        let url = format!(
130            "{}/{}/{}/{}",
131            AMD_KDS_BASE_URL, AMD_KDS_VCEK_PATH, product, "cert_chain",
132        );
133
134        tracing::debug!(url = %url, "Fetching ASK+ARK from AMD KDS");
135
136        let response = self
137            .http
138            .get(&url)
139            .header("Accept", "application/x-pem-file")
140            .send()
141            .await
142            .map_err(|e| {
143                BoxError::AttestationError(format!(
144                    "Failed to fetch cert chain from AMD KDS: {}",
145                    e
146                ))
147            })?;
148
149        if !response.status().is_success() {
150            return Err(BoxError::AttestationError(format!(
151                "AMD KDS returned {} for cert chain request",
152                response.status()
153            )));
154        }
155
156        let pem_bundle = response.bytes().await.map_err(|e| {
157            BoxError::AttestationError(format!("Failed to read cert chain response: {}", e))
158        })?;
159
160        // The PEM bundle contains two certificates: ASK first, then ARK.
161        // Split them by finding the PEM boundaries.
162        Self::split_pem_bundle(&pem_bundle)
163    }
164
165    /// Split a PEM bundle containing ASK and ARK into separate DER blobs.
166    fn split_pem_bundle(bundle: &[u8]) -> Result<(Vec<u8>, Vec<u8>)> {
167        let pem_str = String::from_utf8_lossy(bundle);
168        let certs: Vec<&str> = pem_str
169            .split("-----END CERTIFICATE-----")
170            .filter(|s| s.contains("-----BEGIN CERTIFICATE-----"))
171            .collect();
172
173        if certs.len() < 2 {
174            return Err(BoxError::AttestationError(format!(
175                "Expected 2 certificates in PEM bundle, found {}",
176                certs.len()
177            )));
178        }
179
180        let ask = Self::pem_to_der(certs[0])?;
181        let ark = Self::pem_to_der(certs[1])?;
182
183        Ok((ask, ark))
184    }
185
186    /// Convert a PEM certificate to DER bytes.
187    fn pem_to_der(pem: &str) -> Result<Vec<u8>> {
188        let b64: String = pem
189            .lines()
190            .filter(|line| !line.starts_with("-----") && !line.is_empty())
191            .collect();
192
193        base64_decode(&b64).map_err(|e| {
194            BoxError::AttestationError(format!("Failed to decode PEM certificate: {}", e))
195        })
196    }
197
198    /// Try to load a cached certificate chain.
199    async fn load_from_cache(&self, chip_id: &str, tcb: &TcbVersion) -> Option<CertificateChain> {
200        let cache_dir = self.cache_dir.as_ref()?;
201        let cache_key = Self::cache_key(chip_id, tcb);
202        let cache_path = cache_dir.join(&cache_key);
203
204        let data = tokio::fs::read(&cache_path).await.ok()?;
205        serde_json::from_slice(&data).ok()
206    }
207
208    /// Save a certificate chain to the local cache.
209    async fn save_to_cache(&self, chip_id: &str, tcb: &TcbVersion, chain: &CertificateChain) {
210        let Some(cache_dir) = &self.cache_dir else {
211            return;
212        };
213
214        if let Err(e) = tokio::fs::create_dir_all(cache_dir).await {
215            tracing::warn!("Failed to create cert cache dir: {}", e);
216            return;
217        }
218
219        let cache_key = Self::cache_key(chip_id, tcb);
220        let cache_path = cache_dir.join(&cache_key);
221
222        match serde_json::to_vec(chain) {
223            Ok(data) => {
224                if let Err(e) = tokio::fs::write(&cache_path, &data).await {
225                    tracing::warn!("Failed to cache certificate chain: {}", e);
226                }
227            }
228            Err(e) => {
229                tracing::warn!("Failed to serialize certificate chain for cache: {}", e);
230            }
231        }
232    }
233
234    /// Generate a cache key from chip ID and TCB version.
235    fn cache_key(chip_id: &str, tcb: &TcbVersion) -> String {
236        // Use first 16 chars of chip_id + TCB components for uniqueness
237        let short_id = &chip_id[..chip_id.len().min(16)];
238        format!(
239            "snp_certs_{}_bl{}_tee{}_snp{}_uc{}.json",
240            short_id, tcb.boot_loader, tcb.tee, tcb.snp, tcb.microcode,
241        )
242    }
243
244    /// Get the product name string for AMD KDS.
245    pub fn product_name(generation: &str) -> &'static str {
246        match generation.to_lowercase().as_str() {
247            "milan" => PRODUCT_MILAN,
248            "genoa" => PRODUCT_GENOA,
249            _ => PRODUCT_MILAN, // Default to Milan
250        }
251    }
252}
253
254/// Decode a base64 string (standard alphabet, tolerates whitespace and missing padding).
255fn base64_decode(input: &str) -> std::result::Result<Vec<u8>, String> {
256    use base64::{engine::general_purpose, Engine};
257    // Strip whitespace before decoding (PEM base64 contains newlines)
258    let cleaned: String = input.chars().filter(|c| !c.is_whitespace()).collect();
259    // Use STANDARD_NO_PAD to tolerate both padded and unpadded input
260    general_purpose::STANDARD_NO_PAD
261        .decode(&cleaned)
262        .or_else(|_| general_purpose::STANDARD.decode(&cleaned))
263        .map_err(|e| format!("base64 decode error: {}", e))
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    #[test]
271    fn test_product_name() {
272        assert_eq!(AmdKdsClient::product_name("milan"), "Milan");
273        assert_eq!(AmdKdsClient::product_name("Milan"), "Milan");
274        assert_eq!(AmdKdsClient::product_name("genoa"), "Genoa");
275        assert_eq!(AmdKdsClient::product_name("Genoa"), "Genoa");
276        assert_eq!(AmdKdsClient::product_name("unknown"), "Milan");
277    }
278
279    #[test]
280    fn test_cache_key() {
281        let tcb = TcbVersion {
282            boot_loader: 3,
283            tee: 0,
284            snp: 8,
285            microcode: 115,
286        };
287        let key = AmdKdsClient::cache_key("abcdef1234567890aabbccdd", &tcb);
288        assert_eq!(key, "snp_certs_abcdef1234567890_bl3_tee0_snp8_uc115.json");
289    }
290
291    #[test]
292    fn test_base64_decode() {
293        let encoded = "SGVsbG8gV29ybGQ=";
294        let decoded = base64_decode(encoded).unwrap();
295        assert_eq!(decoded, b"Hello World");
296    }
297
298    #[test]
299    fn test_base64_decode_no_padding() {
300        let encoded = "SGVsbG8";
301        let decoded = base64_decode(encoded).unwrap();
302        assert_eq!(decoded, b"Hello");
303    }
304
305    #[test]
306    fn test_base64_decode_with_newlines() {
307        let encoded = "SGVs\nbG8g\nV29ybGQ=";
308        let decoded = base64_decode(encoded).unwrap();
309        assert_eq!(decoded, b"Hello World");
310    }
311
312    #[test]
313    fn test_split_pem_bundle() {
314        let bundle = b"-----BEGIN CERTIFICATE-----\nAQID\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nBAUG\n-----END CERTIFICATE-----\n";
315        let (ask, ark) = AmdKdsClient::split_pem_bundle(bundle).unwrap();
316        assert_eq!(ask, vec![1, 2, 3]);
317        assert_eq!(ark, vec![4, 5, 6]);
318    }
319
320    #[test]
321    fn test_split_pem_bundle_too_few() {
322        let bundle = b"-----BEGIN CERTIFICATE-----\nAQID\n-----END CERTIFICATE-----\n";
323        let result = AmdKdsClient::split_pem_bundle(bundle);
324        assert!(result.is_err());
325    }
326
327    #[test]
328    fn test_pem_to_der() {
329        let pem = "-----BEGIN CERTIFICATE-----\nAQID\n-----END CERTIFICATE-----";
330        let der = AmdKdsClient::pem_to_der(pem).unwrap();
331        assert_eq!(der, vec![1, 2, 3]);
332    }
333
334    #[test]
335    fn test_kds_client_creation() {
336        let client = AmdKdsClient::new(None);
337        assert!(client.cache_dir.is_none());
338
339        let client = AmdKdsClient::new(Some(PathBuf::from("/tmp/test-certs")));
340        assert_eq!(client.cache_dir, Some(PathBuf::from("/tmp/test-certs")));
341    }
342}