1use a3s_box_core::error::{BoxError, Result};
8use std::path::PathBuf;
9
10use super::attestation::{CertificateChain, TcbVersion};
11
12const AMD_KDS_BASE_URL: &str = "https://kds.amd.com";
14
15const AMD_KDS_VCEK_PATH: &str = "vcek/v1";
17
18const PRODUCT_MILAN: &str = "Milan";
20
21const PRODUCT_GENOA: &str = "Genoa";
23
24pub struct AmdKdsClient {
26 http: reqwest::Client,
28 cache_dir: Option<PathBuf>,
30}
31
32impl AmdKdsClient {
33 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 pub async fn fetch_cert_chain(
57 &self,
58 chip_id: &str,
59 tcb: &TcbVersion,
60 product: &str,
61 ) -> Result<CertificateChain> {
62 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 let vcek = self.fetch_vcek(chip_id, tcb, product).await?;
73
74 let (ask, ark) = self.fetch_ask_ark(product).await?;
76
77 let chain = CertificateChain { vcek, ask, ark };
78
79 self.save_to_cache(chip_id, tcb, &chain).await;
81
82 Ok(chain)
83 }
84
85 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 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 Self::split_pem_bundle(&pem_bundle)
166 }
167
168 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 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 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 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 fn cache_key(chip_id: &str, tcb: &TcbVersion) -> String {
239 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 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 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, }
263 }
264}
265
266fn base64_decode(input: &str) -> std::result::Result<Vec<u8>, String> {
268 use base64::{engine::general_purpose, Engine};
269 let cleaned: String = input.chars().filter(|c| !c.is_whitespace()).collect();
271 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}