Skip to content

Commit a024a16

Browse files
feat: add agent attestation (#188)
Co-authored-by: Robert Yan <46699230+think-in-universe@users.noreply.github.com>
1 parent df9d942 commit a024a16

File tree

2 files changed

+299
-7
lines changed

2 files changed

+299
-7
lines changed

crates/api/src/models.rs

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,12 +144,48 @@ pub struct ModelAttestation {
144144
pub info: Option<serde_json::Value>,
145145
}
146146

147+
/// Agent attestation from agent instance
148+
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
149+
pub struct AgentAttestation {
150+
/// Agent instance name
151+
pub name: String,
152+
153+
/// Container image digest
154+
#[serde(skip_serializing_if = "Option::is_none")]
155+
pub image_digest: Option<String>,
156+
157+
/// TDX event log
158+
#[serde(skip_serializing_if = "Option::is_none")]
159+
pub event_log: Option<String>,
160+
161+
/// Additional TDX/tappd info (structured JSON)
162+
#[serde(skip_serializing_if = "Option::is_none")]
163+
pub info: Option<serde_json::Value>,
164+
165+
/// Intel TDX quote in hex format
166+
#[serde(skip_serializing_if = "Option::is_none")]
167+
pub intel_quote: Option<String>,
168+
169+
/// Request nonce
170+
#[serde(skip_serializing_if = "Option::is_none")]
171+
pub request_nonce: Option<String>,
172+
173+
/// TLS certificate
174+
#[serde(skip_serializing_if = "Option::is_none")]
175+
pub tls_certificate: Option<String>,
176+
177+
/// TLS certificate fingerprint
178+
#[serde(skip_serializing_if = "Option::is_none")]
179+
pub tls_certificate_fingerprint: Option<String>,
180+
}
181+
147182
/// Complete attestation report combining all layers
148183
///
149184
/// This report proves the entire trust chain:
150185
/// 1. This chat-api service runs in a TEE (your_gateway_attestation)
151-
/// 2. The cloud-api dependency runs in a TEE (cloud_api_gateway_attestation)
186+
/// 2. The cloud-api dependency runs in a TEE (cloud_api_gateway_attestation)
152187
/// 3. The model inference providers run on trusted hardware (model_attestations)
188+
/// 4. Optional agent instance attestations when agent parameter is provided
153189
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
154190
pub struct CombinedAttestationReport {
155191
/// This chat-api's own CPU attestation (proves this service runs in a TEE)
@@ -161,6 +197,10 @@ pub struct CombinedAttestationReport {
161197
/// Model provider attestations (can be multiple when routing to different models)
162198
#[serde(skip_serializing_if = "Option::is_none")]
163199
pub model_attestations: Option<Vec<ModelAttestation>>,
200+
201+
/// Agent instance attestations (included when agent query parameter is provided)
202+
#[serde(skip_serializing_if = "Option::is_none")]
203+
pub agent_attestations: Option<Vec<AgentAttestation>>,
164204
}
165205

166206
/// Attestation report structure from proxy_service

crates/api/src/routes/attestation.rs

Lines changed: 258 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,19 @@ use crate::{
44
state::AppState,
55
ApiError,
66
};
7-
use axum::{extract::Query, extract::State, response::Json, routing::get, Router};
7+
use axum::{
8+
extract::{Query, State},
9+
response::Json,
10+
routing::get,
11+
Router,
12+
};
813
use futures::TryStreamExt;
914
use http::Method;
15+
use reqwest::Client;
1016
use serde::{Deserialize, Serialize};
1117
use services::vpc::load_vpc_info;
1218

13-
#[derive(Debug, Deserialize, Serialize)]
19+
#[derive(Debug, Clone, Deserialize, Serialize)]
1420
pub struct AttestationQuery {
1521
/// Optional model name to get specific attestations
1622
#[serde(skip_serializing_if = "Option::is_none")]
@@ -27,6 +33,10 @@ pub struct AttestationQuery {
2733
/// Signing address
2834
#[serde(skip_serializing_if = "Option::is_none")]
2935
pub signing_address: Option<String>,
36+
37+
/// Optional agent instance ID to get agent attestations
38+
#[serde(skip_serializing_if = "Option::is_none")]
39+
pub agent: Option<String>,
3040
}
3141

3242
/// GET /v1/attestation/report
@@ -46,7 +56,8 @@ pub struct AttestationQuery {
4656
("model" = Option<String>, Query, description = "Optional model name to filter model attestations"),
4757
("signing_algo" = Option<String>, Query, description = "Signing algorithm: 'ecdsa' or 'ed25519'"),
4858
("nonce" = Option<String>, Query, description = "64 length (32 bytes) hex string"),
49-
("signing_address" = Option<String>, Query, description = "Query the attestation of the specific model that owns this signing address")
59+
("signing_address" = Option<String>, Query, description = "Query the attestation of the specific model that owns this signing address"),
60+
("agent" = Option<String>, Query, description = "Optional agent instance ID to include agent attestations in response")
5061
),
5162
responses(
5263
(status = 200, description = "Combined attestation report", body = CombinedAttestationReport),
@@ -58,7 +69,11 @@ pub async fn get_attestation_report(
5869
State(app_state): State<AppState>,
5970
Query(params): Query<AttestationQuery>,
6071
) -> Result<Json<CombinedAttestationReport>, ApiError> {
61-
let query = serde_urlencoded::to_string(&params).expect("Failed to serialize query string");
72+
// Exclude agent parameter from cloud-api query since it's not relevant there
73+
let mut cloud_api_params = params.clone();
74+
cloud_api_params.agent = None;
75+
let query =
76+
serde_urlencoded::to_string(&cloud_api_params).expect("Failed to serialize query string");
6277

6378
// Build the path for proxy_service attestation endpoint
6479
let path = format!("attestation/report?{}", query);
@@ -131,7 +146,7 @@ pub async fn get_attestation_report(
131146
signing_algo: None,
132147
intel_quote: "0x1234567890abcdef".to_string(),
133148
event_log: None,
134-
request_nonce,
149+
request_nonce: request_nonce.clone(),
135150
info: None,
136151
vpc: vpc_info,
137152
}
@@ -163,7 +178,7 @@ pub async fn get_attestation_report(
163178
info: Some(serde_json::to_value(info).map_err(|_| {
164179
ApiError::internal_server_error("Failed to serialize attestation info")
165180
})?),
166-
request_nonce,
181+
request_nonce: request_nonce.clone(),
167182
vpc: vpc_info,
168183
}
169184
};
@@ -172,15 +187,252 @@ pub async fn get_attestation_report(
172187

173188
let model_attestations = proxy_report.model_attestations;
174189

190+
// Fetch agent attestations if agent parameter is provided (no user auth required)
191+
let agent_attestations = if let Some(agent_id) = &params.agent {
192+
match fetch_agent_attestations(&app_state, agent_id, &request_nonce).await {
193+
Ok(attestations) => Some(attestations),
194+
Err(e) => {
195+
tracing::warn!("Failed to fetch agent attestations: {:?}", e);
196+
// Don't fail the entire request if agent attestation fetch fails
197+
None
198+
}
199+
}
200+
} else {
201+
None
202+
};
203+
175204
let report = CombinedAttestationReport {
176205
chat_api_gateway_attestation,
177206
cloud_api_gateway_attestation,
178207
model_attestations,
208+
agent_attestations,
179209
};
180210

181211
Ok(Json(report))
182212
}
183213

214+
/// Fetch agent attestations from compose-api
215+
#[derive(Debug, Deserialize)]
216+
struct AgentAttestationResponse {
217+
event_log: Option<String>,
218+
quote: Option<String>,
219+
#[serde(default)]
220+
info: Option<serde_json::Value>,
221+
tls_certificate: Option<String>,
222+
tls_certificate_fingerprint: Option<String>,
223+
}
224+
225+
#[derive(Debug, Deserialize)]
226+
struct AgentInstanceAttestationResponse {
227+
image_digest: Option<String>,
228+
name: String,
229+
}
230+
231+
/// Validate nonce is properly formatted and reasonable length (replay protection)
232+
fn validate_nonce(nonce: &str) -> Result<(), ApiError> {
233+
// Nonce should be a valid hex string of reasonable length (64 chars = 32 bytes)
234+
const EXPECTED_NONCE_LEN: usize = 64;
235+
const MAX_NONCE_LEN: usize = 256;
236+
237+
if nonce.len() > MAX_NONCE_LEN {
238+
tracing::warn!("Nonce exceeds maximum length: {}", nonce.len());
239+
return Err(ApiError::bad_request("Nonce is too long"));
240+
}
241+
242+
if !nonce.chars().all(|c| c.is_ascii_hexdigit()) {
243+
tracing::warn!("Nonce contains non-hex characters");
244+
return Err(ApiError::bad_request("Nonce must be a valid hex string"));
245+
}
246+
247+
if nonce.len() != EXPECTED_NONCE_LEN {
248+
tracing::warn!(
249+
"Nonce has unexpected length: {} (expected {})",
250+
nonce.len(),
251+
EXPECTED_NONCE_LEN
252+
);
253+
return Err(ApiError::bad_request(format!(
254+
"Nonce must be exactly {} characters",
255+
EXPECTED_NONCE_LEN
256+
)));
257+
}
258+
259+
Ok(())
260+
}
261+
262+
/// Validate instance name doesn't contain path traversal sequences
263+
fn validate_instance_name(name: &str) -> Result<(), ApiError> {
264+
// Reject names containing path traversal sequences
265+
if name.contains("..") || name.contains("/") || name.contains("\\") {
266+
tracing::warn!("Instance name contains invalid characters: {}", name);
267+
return Err(ApiError::bad_request(
268+
"Instance name contains invalid characters",
269+
));
270+
}
271+
272+
if name.is_empty() {
273+
return Err(ApiError::bad_request("Instance name cannot be empty"));
274+
}
275+
276+
Ok(())
277+
}
278+
279+
/// Build full URL for agent manager request (handles base URL with/without trailing slash)
280+
fn build_manager_url(base_url: &str, path: &str) -> Result<String, ApiError> {
281+
let base = url::Url::parse(base_url).map_err(|e| {
282+
tracing::error!("Invalid agent manager URL {}: {}", base_url, e);
283+
ApiError::internal_server_error("Invalid agent manager URL")
284+
})?;
285+
let full = base.join(path).map_err(|e| {
286+
tracing::error!("Failed to build manager URL: {}", e);
287+
ApiError::internal_server_error("Failed to build manager URL")
288+
})?;
289+
Ok(full.to_string())
290+
}
291+
292+
/// Helper to handle HTTP response from agent manager (status check + body)
293+
async fn handle_manager_response(
294+
response: reqwest::Response,
295+
context: &str,
296+
) -> Result<bytes::Bytes, ApiError> {
297+
let status = response.status();
298+
if !status.is_success() {
299+
tracing::error!(
300+
"Agent manager returned error status {} for {}",
301+
status,
302+
context
303+
);
304+
return Err(ApiError::service_unavailable(format!(
305+
"{} service returned error: {}",
306+
context, status
307+
)));
308+
}
309+
response.bytes().await.map_err(|e| {
310+
tracing::error!("Failed to read {} response: {}", context, e);
311+
ApiError::internal_server_error(format!("Failed to read {} response", context))
312+
})
313+
}
314+
315+
async fn fetch_agent_attestations(
316+
app_state: &AppState,
317+
agent_id: &str,
318+
request_nonce: &str,
319+
) -> Result<Vec<crate::models::AgentAttestation>, ApiError> {
320+
use uuid::Uuid;
321+
322+
// Security: Validate nonce to prevent panic/DoS from malformed input
323+
validate_nonce(request_nonce)?;
324+
325+
// Parse the agent_id as UUID
326+
let agent_uuid = Uuid::parse_str(agent_id).map_err(|e| {
327+
tracing::error!("Invalid agent ID format: {}", e);
328+
ApiError::bad_request(format!("Invalid agent ID format: {}", e))
329+
})?;
330+
331+
// Fetch the agent instance from database (no user_id check - attestation is public)
332+
let agent_instance = app_state
333+
.agent_repository
334+
.get_instance(agent_uuid)
335+
.await
336+
.map_err(|e| {
337+
tracing::error!("Failed to fetch agent instance from database: {}", e);
338+
ApiError::internal_server_error("Failed to fetch agent instance")
339+
})?
340+
.ok_or_else(|| {
341+
tracing::warn!("Agent instance not found: {}", agent_id);
342+
ApiError::not_found("Agent instance not found")
343+
})?;
344+
345+
// Security: Validate instance name to prevent path traversal attacks
346+
validate_instance_name(&agent_instance.name)?;
347+
348+
// Get the agent manager URL - each instance is hosted on a specific manager
349+
let manager_base_url = agent_instance
350+
.agent_api_base_url
351+
.as_deref()
352+
.ok_or_else(|| {
353+
tracing::warn!("Agent instance has no agent_api_base_url: {}", agent_id);
354+
ApiError::bad_gateway("Agent instance has no manager URL; cannot fetch attestation")
355+
})?;
356+
357+
let instance_name = &agent_instance.name;
358+
// URL-encode instance name for safe URL construction
359+
let encoded_instance_name = urlencoding::encode(instance_name);
360+
361+
// Build URLs for the manager that hosts this instance
362+
// NOTE: Nonce is critical for replay protection - bind the quote to the client's nonce
363+
let attestation_url = build_manager_url(
364+
manager_base_url,
365+
&format!("attestation/report?nonce={}", request_nonce),
366+
)?;
367+
let instance_attestation_url = build_manager_url(
368+
manager_base_url,
369+
&format!("instances/{}/attestation", encoded_instance_name),
370+
)?;
371+
372+
// Fetch both attestations concurrently from the corresponding agent manager
373+
let http_client = Client::builder()
374+
.timeout(std::time::Duration::from_secs(30))
375+
.build()
376+
.map_err(|e| {
377+
tracing::error!("Failed to create HTTP client: {}", e);
378+
ApiError::internal_server_error("Failed to create HTTP client")
379+
})?;
380+
381+
let (attestation_response, instance_response) = tokio::join!(
382+
http_client.get(&attestation_url).send(),
383+
http_client.get(&instance_attestation_url).send(),
384+
);
385+
386+
let attestation_response = attestation_response.map_err(|e| {
387+
tracing::error!(
388+
"Failed to fetch agent attestation from manager {}: {}",
389+
manager_base_url,
390+
e
391+
);
392+
ApiError::bad_gateway(format!("Failed to fetch agent attestation: {}", e))
393+
})?;
394+
395+
let attestation_bytes =
396+
handle_manager_response(attestation_response, "Agent attestation").await?;
397+
398+
let attestation_data: AgentAttestationResponse = serde_json::from_slice(&attestation_bytes)
399+
.map_err(|e| {
400+
tracing::error!("Failed to parse agent attestation response: {}", e);
401+
ApiError::internal_server_error("Failed to parse agent attestation")
402+
})?;
403+
404+
let instance_response = instance_response.map_err(|e| {
405+
tracing::error!(
406+
"Failed to fetch instance attestation from manager {}: {}",
407+
manager_base_url,
408+
e
409+
);
410+
ApiError::bad_gateway(format!("Failed to fetch instance attestation: {}", e))
411+
})?;
412+
413+
let instance_bytes = handle_manager_response(instance_response, "Instance attestation").await?;
414+
415+
let instance_data: AgentInstanceAttestationResponse = serde_json::from_slice(&instance_bytes)
416+
.map_err(|e| {
417+
tracing::error!("Failed to parse instance attestation response: {}", e);
418+
ApiError::internal_server_error("Failed to parse instance attestation")
419+
})?;
420+
421+
// Combine the data
422+
let agent_attestation = crate::models::AgentAttestation {
423+
name: instance_data.name,
424+
image_digest: instance_data.image_digest,
425+
event_log: attestation_data.event_log,
426+
info: attestation_data.info,
427+
intel_quote: attestation_data.quote,
428+
request_nonce: Some(request_nonce.to_string()),
429+
tls_certificate: attestation_data.tls_certificate,
430+
tls_certificate_fingerprint: attestation_data.tls_certificate_fingerprint,
431+
};
432+
433+
Ok(vec![agent_attestation])
434+
}
435+
184436
/// Create the attestation router
185437
pub fn create_attestation_router() -> Router<AppState> {
186438
Router::new().route("/v1/attestation/report", get(get_attestation_report))

0 commit comments

Comments
 (0)