@@ -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+ } ;
813use futures:: TryStreamExt ;
914use http:: Method ;
15+ use reqwest:: Client ;
1016use serde:: { Deserialize , Serialize } ;
1117use services:: vpc:: load_vpc_info;
1218
13- #[ derive( Debug , Deserialize , Serialize ) ]
19+ #[ derive( Debug , Clone , Deserialize , Serialize ) ]
1420pub 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
185437pub fn create_attestation_router ( ) -> Router < AppState > {
186438 Router :: new ( ) . route ( "/v1/attestation/report" , get ( get_attestation_report) )
0 commit comments