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!(
65                chip_id = Self::short_chip_id(chip_id),
66                "Using cached certificate chain"
67            );
68            return Ok(cached);
69        }
70
71        // Fetch VCEK certificate
72        let vcek = self.fetch_vcek(chip_id, tcb, product).await?;
73
74        // Fetch ASK + ARK certificate chain
75        let (ask, ark) = self.fetch_ask_ark(product).await?;
76
77        let chain = CertificateChain { vcek, ask, ark };
78
79        // Cache for future use
80        self.save_to_cache(chip_id, tcb, &chain).await;
81
82        Ok(chain)
83    }
84
85    /// Fetch the VCEK certificate from AMD KDS.
86    ///
87    /// URL format: `https://kds.amd.com/vcek/v1/{product}/{chip_id}?blSPL={bl}&teeSPL={tee}&snpSPL={snp}&ucodeSPL={ucode}`
88    async fn fetch_vcek(&self, chip_id: &str, tcb: &TcbVersion, product: &str) -> Result<Vec<u8>> {
89        let url = format!(
90            "{}/{}/{}/{}?blSPL={}&teeSPL={}&snpSPL={}&ucodeSPL={}",
91            AMD_KDS_BASE_URL,
92            AMD_KDS_VCEK_PATH,
93            product,
94            chip_id,
95            tcb.boot_loader,
96            tcb.tee,
97            tcb.snp,
98            tcb.microcode,
99        );
100
101        tracing::debug!(url = %url, "Fetching VCEK from AMD KDS");
102
103        let response = self
104            .http
105            .get(&url)
106            .header("Accept", "application/x-pem-file")
107            .send()
108            .await
109            .map_err(|e| {
110                BoxError::AttestationError(format!("Failed to fetch VCEK from AMD KDS: {}", e))
111            })?;
112
113        if !response.status().is_success() {
114            return Err(BoxError::AttestationError(format!(
115                "AMD KDS returned {} for VCEK request",
116                response.status()
117            )));
118        }
119
120        response
121            .bytes()
122            .await
123            .map(|b| b.to_vec())
124            .map_err(|e| BoxError::AttestationError(format!("Failed to read VCEK response: {}", e)))
125    }
126
127    /// Fetch the ASK and ARK certificates from AMD KDS.
128    ///
129    /// URL: `https://kds.amd.com/vcek/v1/{product}/cert_chain`
130    /// Returns a PEM bundle containing both ASK and ARK.
131    async fn fetch_ask_ark(&self, product: &str) -> Result<(Vec<u8>, Vec<u8>)> {
132        let url = format!(
133            "{}/{}/{}/{}",
134            AMD_KDS_BASE_URL, AMD_KDS_VCEK_PATH, product, "cert_chain",
135        );
136
137        tracing::debug!(url = %url, "Fetching ASK+ARK from AMD KDS");
138
139        let response = self
140            .http
141            .get(&url)
142            .header("Accept", "application/x-pem-file")
143            .send()
144            .await
145            .map_err(|e| {
146                BoxError::AttestationError(format!(
147                    "Failed to fetch cert chain from AMD KDS: {}",
148                    e
149                ))
150            })?;
151
152        if !response.status().is_success() {
153            return Err(BoxError::AttestationError(format!(
154                "AMD KDS returned {} for cert chain request",
155                response.status()
156            )));
157        }
158
159        let pem_bundle = response.bytes().await.map_err(|e| {
160            BoxError::AttestationError(format!("Failed to read cert chain response: {}", e))
161        })?;
162
163        // The PEM bundle contains two certificates: ASK first, then ARK.
164        // Split them by finding the PEM boundaries.
165        Self::split_pem_bundle(&pem_bundle)
166    }
167
168    /// Split a PEM bundle containing ASK and ARK into separate DER blobs.
169    fn split_pem_bundle(bundle: &[u8]) -> Result<(Vec<u8>, Vec<u8>)> {
170        let pem_str = String::from_utf8_lossy(bundle);
171        let certs: Vec<&str> = pem_str
172            .split("-----END CERTIFICATE-----")
173            .filter(|s| s.contains("-----BEGIN CERTIFICATE-----"))
174            .collect();
175
176        if certs.len() < 2 {
177            return Err(BoxError::AttestationError(format!(
178                "Expected 2 certificates in PEM bundle, found {}",
179                certs.len()
180            )));
181        }
182
183        let ask = Self::pem_to_der(certs[0])?;
184        let ark = Self::pem_to_der(certs[1])?;
185
186        Ok((ask, ark))
187    }
188
189    /// Convert a PEM certificate to DER bytes.
190    fn pem_to_der(pem: &str) -> Result<Vec<u8>> {
191        let b64: String = pem
192            .lines()
193            .filter(|line| !line.starts_with("-----") && !line.is_empty())
194            .collect();
195
196        base64_decode(&b64).map_err(|e| {
197            BoxError::AttestationError(format!("Failed to decode PEM certificate: {}", e))
198        })
199    }
200
201    /// Try to load a cached certificate chain.
202    async fn load_from_cache(&self, chip_id: &str, tcb: &TcbVersion) -> Option<CertificateChain> {
203        let cache_dir = self.cache_dir.as_ref()?;
204        let cache_key = Self::cache_key(chip_id, tcb);
205        let cache_path = cache_dir.join(&cache_key);
206
207        let data = tokio::fs::read(&cache_path).await.ok()?;
208        serde_json::from_slice(&data).ok()
209    }
210
211    /// Save a certificate chain to the local cache.
212    async fn save_to_cache(&self, chip_id: &str, tcb: &TcbVersion, chain: &CertificateChain) {
213        let Some(cache_dir) = &self.cache_dir else {
214            return;
215        };
216
217        if let Err(e) = tokio::fs::create_dir_all(cache_dir).await {
218            tracing::warn!("Failed to create cert cache dir: {}", e);
219            return;
220        }
221
222        let cache_key = Self::cache_key(chip_id, tcb);
223        let cache_path = cache_dir.join(&cache_key);
224
225        match serde_json::to_vec(chain) {
226            Ok(data) => {
227                if let Err(e) = tokio::fs::write(&cache_path, &data).await {
228                    tracing::warn!("Failed to cache certificate chain: {}", e);
229                }
230            }
231            Err(e) => {
232                tracing::warn!("Failed to serialize certificate chain for cache: {}", e);
233            }
234        }
235    }
236
237    /// Generate a cache key from chip ID and TCB version.
238    fn cache_key(chip_id: &str, tcb: &TcbVersion) -> String {
239        // Use first 16 chars of chip_id + TCB components for uniqueness
240        let short_id = Self::short_chip_id(chip_id);
241        format!(
242            "snp_certs_{}_bl{}_tee{}_snp{}_uc{}.json",
243            short_id, tcb.boot_loader, tcb.tee, tcb.snp, tcb.microcode,
244        )
245    }
246
247    /// Return a UTF-8 safe chip ID prefix for logs and cache keys.
248    fn short_chip_id(chip_id: &str) -> &str {
249        chip_id
250            .char_indices()
251            .nth(16)
252            .map(|(idx, _)| &chip_id[..idx])
253            .unwrap_or(chip_id)
254    }
255
256    /// Get the product name string for AMD KDS.
257    pub fn product_name(generation: &str) -> &'static str {
258        match generation.to_lowercase().as_str() {
259            "milan" => PRODUCT_MILAN,
260            "genoa" => PRODUCT_GENOA,
261            _ => PRODUCT_MILAN, // Default to Milan
262        }
263    }
264}
265
266/// Decode a base64 string (standard alphabet, tolerates whitespace and missing padding).
267fn base64_decode(input: &str) -> std::result::Result<Vec<u8>, String> {
268    use base64::{engine::general_purpose, Engine};
269    // Strip whitespace before decoding (PEM base64 contains newlines)
270    let cleaned: String = input.chars().filter(|c| !c.is_whitespace()).collect();
271    // Use STANDARD_NO_PAD to tolerate both padded and unpadded input
272    general_purpose::STANDARD_NO_PAD
273        .decode(&cleaned)
274        .or_else(|_| general_purpose::STANDARD.decode(&cleaned))
275        .map_err(|e| format!("base64 decode error: {}", e))
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    fn sample_tcb() -> TcbVersion {
283        TcbVersion {
284            boot_loader: 3,
285            tee: 1,
286            snp: 8,
287            microcode: 115,
288        }
289    }
290
291    fn sample_chain() -> CertificateChain {
292        CertificateChain {
293            vcek: vec![1, 2, 3],
294            ask: vec![4, 5, 6],
295            ark: vec![7, 8, 9],
296        }
297    }
298
299    fn assert_chain_eq(actual: &CertificateChain, expected: &CertificateChain) {
300        assert_eq!(actual.vcek, expected.vcek);
301        assert_eq!(actual.ask, expected.ask);
302        assert_eq!(actual.ark, expected.ark);
303    }
304
305    #[test]
306    fn test_product_name() {
307        assert_eq!(AmdKdsClient::product_name("milan"), "Milan");
308        assert_eq!(AmdKdsClient::product_name("Milan"), "Milan");
309        assert_eq!(AmdKdsClient::product_name("MILAN"), "Milan");
310        assert_eq!(AmdKdsClient::product_name("genoa"), "Genoa");
311        assert_eq!(AmdKdsClient::product_name("Genoa"), "Genoa");
312        assert_eq!(AmdKdsClient::product_name("GENOA"), "Genoa");
313        assert_eq!(AmdKdsClient::product_name("unknown"), "Milan");
314    }
315
316    #[test]
317    fn test_cache_key() {
318        let tcb = TcbVersion {
319            boot_loader: 3,
320            tee: 0,
321            snp: 8,
322            microcode: 115,
323        };
324        let key = AmdKdsClient::cache_key("abcdef1234567890aabbccdd", &tcb);
325        assert_eq!(key, "snp_certs_abcdef1234567890_bl3_tee0_snp8_uc115.json");
326    }
327
328    #[test]
329    fn test_cache_key_handles_short_and_multibyte_chip_ids() {
330        let tcb = sample_tcb();
331
332        let short_key = AmdKdsClient::cache_key("abc", &tcb);
333        assert_eq!(short_key, "snp_certs_abc_bl3_tee1_snp8_uc115.json");
334
335        let multibyte_key = AmdKdsClient::cache_key("芯片abcdefghijklmnop", &tcb);
336        assert_eq!(
337            multibyte_key,
338            "snp_certs_芯片abcdefghijklmn_bl3_tee1_snp8_uc115.json"
339        );
340    }
341
342    #[test]
343    fn test_base64_decode() {
344        let encoded = "SGVsbG8gV29ybGQ=";
345        let decoded = base64_decode(encoded).unwrap();
346        assert_eq!(decoded, b"Hello World");
347    }
348
349    #[test]
350    fn test_base64_decode_no_padding() {
351        let encoded = "SGVsbG8";
352        let decoded = base64_decode(encoded).unwrap();
353        assert_eq!(decoded, b"Hello");
354    }
355
356    #[test]
357    fn test_base64_decode_with_newlines() {
358        let encoded = "SGVs\nbG8g\nV29ybGQ=";
359        let decoded = base64_decode(encoded).unwrap();
360        assert_eq!(decoded, b"Hello World");
361    }
362
363    #[test]
364    fn test_base64_decode_rejects_invalid_input() {
365        let err = base64_decode("not base64!!!").unwrap_err();
366        assert!(err.contains("base64 decode error"));
367    }
368
369    #[test]
370    fn test_split_pem_bundle() {
371        let bundle = b"-----BEGIN CERTIFICATE-----\nAQID\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nBAUG\n-----END CERTIFICATE-----\n";
372        let (ask, ark) = AmdKdsClient::split_pem_bundle(bundle).unwrap();
373        assert_eq!(ask, vec![1, 2, 3]);
374        assert_eq!(ark, vec![4, 5, 6]);
375    }
376
377    #[test]
378    fn test_split_pem_bundle_too_few() {
379        let bundle = b"-----BEGIN CERTIFICATE-----\nAQID\n-----END CERTIFICATE-----\n";
380        let result = AmdKdsClient::split_pem_bundle(bundle);
381        assert!(result.is_err());
382    }
383
384    #[test]
385    fn test_split_pem_bundle_rejects_invalid_certificate_body() {
386        let bundle = b"-----BEGIN CERTIFICATE-----\nAQID\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nnot base64!!!\n-----END CERTIFICATE-----\n";
387        let err = AmdKdsClient::split_pem_bundle(bundle).unwrap_err();
388        assert!(err.to_string().contains("Failed to decode PEM certificate"));
389    }
390
391    #[test]
392    fn test_pem_to_der() {
393        let pem = "-----BEGIN CERTIFICATE-----\nAQID\n-----END CERTIFICATE-----";
394        let der = AmdKdsClient::pem_to_der(pem).unwrap();
395        assert_eq!(der, vec![1, 2, 3]);
396    }
397
398    #[test]
399    fn test_pem_to_der_accepts_whitespace_and_padding_variants() {
400        let pem = "-----BEGIN CERTIFICATE-----\n AQ\nID\n-----END CERTIFICATE-----";
401        let der = AmdKdsClient::pem_to_der(pem).unwrap();
402        assert_eq!(der, vec![1, 2, 3]);
403    }
404
405    #[test]
406    fn test_pem_to_der_rejects_invalid_base64() {
407        let pem = "-----BEGIN CERTIFICATE-----\nnot base64!!!\n-----END CERTIFICATE-----";
408        let err = AmdKdsClient::pem_to_der(pem).unwrap_err();
409        assert!(err.to_string().contains("Failed to decode PEM certificate"));
410    }
411
412    #[test]
413    fn test_kds_client_creation() {
414        let client = AmdKdsClient::new(None);
415        assert!(client.cache_dir.is_none());
416
417        let client = AmdKdsClient::new(Some(PathBuf::from("/tmp/test-certs")));
418        assert_eq!(client.cache_dir, Some(PathBuf::from("/tmp/test-certs")));
419    }
420
421    #[tokio::test]
422    async fn test_save_and_load_cache_roundtrip() {
423        let temp = tempfile::tempdir().unwrap();
424        let client = AmdKdsClient::new(Some(temp.path().to_path_buf()));
425        let tcb = sample_tcb();
426        let chain = sample_chain();
427
428        client.save_to_cache("abcdef1234567890", &tcb, &chain).await;
429        let loaded = client.load_from_cache("abcdef1234567890", &tcb).await;
430
431        assert_chain_eq(&loaded.unwrap(), &chain);
432    }
433
434    #[tokio::test]
435    async fn test_fetch_cert_chain_uses_cache_without_network() {
436        let temp = tempfile::tempdir().unwrap();
437        let client = AmdKdsClient::new(Some(temp.path().to_path_buf()));
438        let tcb = sample_tcb();
439        let chain = sample_chain();
440
441        client.save_to_cache("abc", &tcb, &chain).await;
442        let loaded = client
443            .fetch_cert_chain("abc", &tcb, "unreachable-product")
444            .await
445            .unwrap();
446
447        assert_chain_eq(&loaded, &chain);
448    }
449
450    #[tokio::test]
451    async fn test_load_from_cache_returns_none_for_missing_disabled_and_invalid_cache() {
452        let tcb = sample_tcb();
453        let disabled_client = AmdKdsClient::new(None);
454        assert!(disabled_client.load_from_cache("abc", &tcb).await.is_none());
455
456        let temp = tempfile::tempdir().unwrap();
457        let client = AmdKdsClient::new(Some(temp.path().to_path_buf()));
458        assert!(client.load_from_cache("abc", &tcb).await.is_none());
459
460        tokio::fs::create_dir_all(temp.path()).await.unwrap();
461        let cache_path = temp.path().join(AmdKdsClient::cache_key("abc", &tcb));
462        tokio::fs::write(&cache_path, b"not json").await.unwrap();
463
464        assert!(client.load_from_cache("abc", &tcb).await.is_none());
465    }
466
467    #[tokio::test]
468    async fn test_save_to_cache_noops_without_cache_dir() {
469        let client = AmdKdsClient::new(None);
470        client
471            .save_to_cache("abc", &sample_tcb(), &sample_chain())
472            .await;
473    }
474
475    #[tokio::test]
476    async fn test_save_to_cache_ignores_cache_directory_creation_failure() {
477        let temp = tempfile::tempdir().unwrap();
478        let cache_file = temp.path().join("cert-cache-file");
479        tokio::fs::write(&cache_file, b"still a file")
480            .await
481            .unwrap();
482        let client = AmdKdsClient::new(Some(cache_file.clone()));
483
484        client
485            .save_to_cache("abc", &sample_tcb(), &sample_chain())
486            .await;
487
488        assert_eq!(tokio::fs::read(&cache_file).await.unwrap(), b"still a file");
489    }
490}