@@ -310,6 +310,323 @@ mod async_impl {
310310 }
311311}
312312
313+ // ============================================================================
314+ // PinnedHttpClient - HTTP client with certificate pinning
315+ // ============================================================================
316+
317+ /// HTTP client with certificate pinning support.
318+ ///
319+ /// This client enforces certificate pinning for TLS connections, providing
320+ /// defense-in-depth against CA compromise and MITM attacks.
321+ ///
322+ /// Uses the same `PinningConfig` for both sync (ureq) and async (reqwest) modes.
323+ #[ cfg( not( target_arch = "wasm32" ) ) ]
324+ #[ derive( Debug , Clone ) ]
325+ pub struct PinnedHttpClient {
326+ /// User-Agent header value
327+ user_agent : String ,
328+ /// Request timeout in seconds
329+ timeout_secs : u64 ,
330+ /// Pinned rustls configuration
331+ tls_config : std:: sync:: Arc < rustls:: ClientConfig > ,
332+ }
333+
334+ #[ cfg( not( target_arch = "wasm32" ) ) ]
335+ impl PinnedHttpClient {
336+ /// Create a new HTTP client with certificate pinning.
337+ ///
338+ /// # Arguments
339+ /// * `pinning` - Certificate pinning configuration
340+ ///
341+ /// # Returns
342+ /// A new client configured with certificate pinning
343+ pub fn new (
344+ pinning : crate :: signature:: keyless:: cert_pinning:: PinningConfig ,
345+ ) -> Result < Self , WSError > {
346+ let tls_config =
347+ crate :: signature:: keyless:: cert_pinning:: create_pinned_rustls_config ( pinning) ?;
348+
349+ Ok ( Self {
350+ user_agent : format ! ( "wsc/{}" , env!( "CARGO_PKG_VERSION" ) ) ,
351+ timeout_secs : 30 ,
352+ tls_config,
353+ } )
354+ }
355+
356+ /// Create a client with custom timeout
357+ pub fn with_timeout ( mut self , timeout_secs : u64 ) -> Self {
358+ self . timeout_secs = timeout_secs;
359+ self
360+ }
361+
362+ /// Create a client with custom user agent
363+ pub fn with_user_agent ( mut self , user_agent : impl Into < String > ) -> Self {
364+ self . user_agent = user_agent. into ( ) ;
365+ self
366+ }
367+ }
368+
369+ // Synchronous implementation for PinnedHttpClient using ureq
370+ #[ cfg( all( feature = "sync" , not( target_arch = "wasm32" ) ) ) ]
371+ mod pinned_sync_impl {
372+ use super :: * ;
373+ use std:: convert:: TryInto ;
374+ use std:: fmt;
375+ use ureq:: http;
376+ use ureq:: unversioned:: resolver:: DefaultResolver ;
377+ use ureq:: unversioned:: transport:: { Connector , TcpConnector } ;
378+
379+ #[ maybe_async:: sync_impl]
380+ impl HttpClient for PinnedHttpClient {
381+ fn get (
382+ & self ,
383+ url : & str ,
384+ headers : & HashMap < String , String > ,
385+ ) -> Result < HttpResponse , WSError > {
386+ let agent = self . create_pinned_agent ( ) ?;
387+
388+ let mut request = agent. get ( url) ;
389+ request = request. header ( "User-Agent" , & self . user_agent ) ;
390+
391+ for ( key, value) in headers {
392+ request = request. header ( key, value) ;
393+ }
394+
395+ let response = request
396+ . call ( )
397+ . map_err ( |e| WSError :: InternalError ( format ! ( "HTTP GET failed: {}" , e) ) ) ?;
398+
399+ convert_ureq_response ( response)
400+ }
401+
402+ fn post (
403+ & self ,
404+ url : & str ,
405+ body : & [ u8 ] ,
406+ content_type : & str ,
407+ headers : & HashMap < String , String > ,
408+ ) -> Result < HttpResponse , WSError > {
409+ let agent = self . create_pinned_agent ( ) ?;
410+
411+ let mut request = agent. post ( url) ;
412+ request = request. header ( "User-Agent" , & self . user_agent ) ;
413+ request = request. header ( "Content-Type" , content_type) ;
414+
415+ for ( key, value) in headers {
416+ request = request. header ( key, value) ;
417+ }
418+
419+ let response = request
420+ . send ( body)
421+ . map_err ( |e| WSError :: InternalError ( format ! ( "HTTP POST failed: {}" , e) ) ) ?;
422+
423+ convert_ureq_response ( response)
424+ }
425+ }
426+
427+ impl PinnedHttpClient {
428+ fn create_pinned_agent ( & self ) -> Result < ureq:: Agent , WSError > {
429+ // Create connector chain with our pinned TLS config
430+ let pinned_connector = PinnedRustlsConnectorFromConfig :: new ( self . tls_config . clone ( ) ) ;
431+
432+ let connector = ( )
433+ . chain ( TcpConnector :: default ( ) )
434+ . chain ( pinned_connector) ;
435+
436+ let config = ureq:: config:: Config :: builder ( )
437+ . http_status_as_error ( false )
438+ . timeout_global ( Some ( std:: time:: Duration :: from_secs ( self . timeout_secs ) ) )
439+ . build ( ) ;
440+
441+ Ok ( ureq:: Agent :: with_parts ( config, connector, DefaultResolver :: default ( ) ) )
442+ }
443+ }
444+
445+ fn convert_ureq_response ( response : http:: Response < ureq:: Body > ) -> Result < HttpResponse , WSError > {
446+ let status = response. status ( ) . as_u16 ( ) ;
447+ let mut response_headers = HashMap :: new ( ) ;
448+ for ( name, value) in response. headers ( ) {
449+ if let Ok ( v) = value. to_str ( ) {
450+ response_headers. insert ( name. to_string ( ) , v. to_string ( ) ) ;
451+ }
452+ }
453+
454+ let body = response
455+ . into_body ( )
456+ . read_to_vec ( )
457+ . map_err ( |e| WSError :: InternalError ( format ! ( "Failed to read response body: {}" , e) ) ) ?;
458+
459+ Ok ( HttpResponse {
460+ status,
461+ body,
462+ headers : response_headers,
463+ } )
464+ }
465+
466+ /// Ureq connector that uses a pre-configured rustls ClientConfig
467+ struct PinnedRustlsConnectorFromConfig {
468+ config : std:: sync:: Arc < rustls:: ClientConfig > ,
469+ }
470+
471+ impl PinnedRustlsConnectorFromConfig {
472+ fn new ( config : std:: sync:: Arc < rustls:: ClientConfig > ) -> Self {
473+ Self { config }
474+ }
475+ }
476+
477+ impl fmt:: Debug for PinnedRustlsConnectorFromConfig {
478+ fn fmt ( & self , f : & mut fmt:: Formatter < ' _ > ) -> fmt:: Result {
479+ f. debug_struct ( "PinnedRustlsConnectorFromConfig" )
480+ . field ( "config" , & "rustls::ClientConfig" )
481+ . finish ( )
482+ }
483+ }
484+
485+ impl < In : ureq:: unversioned:: transport:: Transport >
486+ ureq:: unversioned:: transport:: Connector < In > for PinnedRustlsConnectorFromConfig
487+ {
488+ type Out = ureq:: unversioned:: transport:: Either <
489+ In ,
490+ crate :: signature:: keyless:: transport:: PinnedRustlsTransport ,
491+ > ;
492+
493+ fn connect (
494+ & self ,
495+ details : & ureq:: unversioned:: transport:: ConnectionDetails ,
496+ chained : Option < In > ,
497+ ) -> Result < Option < Self :: Out > , ureq:: Error > {
498+ use rustls:: ClientConnection ;
499+ use ureq:: unversioned:: transport:: { Either , LazyBuffers , TransportAdapter } ;
500+
501+ let Some ( transport) = chained else {
502+ panic ! ( "PinnedRustlsConnectorFromConfig requires a chained transport" ) ;
503+ } ;
504+
505+ if !details. needs_tls ( ) || transport. is_tls ( ) {
506+ return Ok ( Some ( Either :: A ( transport) ) ) ;
507+ }
508+
509+ let name_borrowed: rustls_pki_types:: ServerName < ' _ > = details
510+ . uri
511+ . authority ( )
512+ . expect ( "uri authority for tls" )
513+ . host ( )
514+ . try_into ( )
515+ . map_err ( |e| {
516+ log:: debug!( "Invalid DNS name: {}" , e) ;
517+ ureq:: Error :: Tls ( "Invalid DNS name for TLS" )
518+ } ) ?;
519+ let name = name_borrowed. to_owned ( ) ;
520+
521+ let conn = ClientConnection :: new ( self . config . clone ( ) , name) ?;
522+ let stream = rustls:: StreamOwned {
523+ conn,
524+ sock : TransportAdapter :: new ( transport. boxed ( ) ) ,
525+ } ;
526+
527+ let buffers = LazyBuffers :: new (
528+ details. config . input_buffer_size ( ) ,
529+ details. config . output_buffer_size ( ) ,
530+ ) ;
531+
532+ Ok ( Some ( Either :: B (
533+ crate :: signature:: keyless:: transport:: PinnedRustlsTransport :: new ( buffers, stream) ,
534+ ) ) )
535+ }
536+ }
537+ }
538+
539+ // Asynchronous implementation for PinnedHttpClient using reqwest
540+ #[ cfg( all( feature = "async" , not( target_arch = "wasm32" ) ) ) ]
541+ mod pinned_async_impl {
542+ use super :: * ;
543+
544+ #[ maybe_async:: async_impl]
545+ impl HttpClient for PinnedHttpClient {
546+ async fn get (
547+ & self ,
548+ url : & str ,
549+ headers : & HashMap < String , String > ,
550+ ) -> Result < HttpResponse , WSError > {
551+ let client = self . create_pinned_client ( ) ?;
552+
553+ let mut request = client. get ( url) ;
554+
555+ for ( key, value) in headers {
556+ request = request. header ( key, value) ;
557+ }
558+
559+ let response = request
560+ . send ( )
561+ . await
562+ . map_err ( |e| WSError :: InternalError ( format ! ( "HTTP GET failed: {}" , e) ) ) ?;
563+
564+ Self :: convert_response ( response) . await
565+ }
566+
567+ async fn post (
568+ & self ,
569+ url : & str ,
570+ body : & [ u8 ] ,
571+ content_type : & str ,
572+ headers : & HashMap < String , String > ,
573+ ) -> Result < HttpResponse , WSError > {
574+ let client = self . create_pinned_client ( ) ?;
575+
576+ let mut request = client
577+ . post ( url)
578+ . header ( "Content-Type" , content_type)
579+ . body ( body. to_vec ( ) ) ;
580+
581+ for ( key, value) in headers {
582+ request = request. header ( key, value) ;
583+ }
584+
585+ let response = request
586+ . send ( )
587+ . await
588+ . map_err ( |e| WSError :: InternalError ( format ! ( "HTTP POST failed: {}" , e) ) ) ?;
589+
590+ Self :: convert_response ( response) . await
591+ }
592+ }
593+
594+ impl PinnedHttpClient {
595+ fn create_pinned_client ( & self ) -> Result < reqwest:: Client , WSError > {
596+ reqwest:: Client :: builder ( )
597+ . user_agent ( & self . user_agent )
598+ . timeout ( std:: time:: Duration :: from_secs ( self . timeout_secs ) )
599+ . use_preconfigured_tls ( ( * self . tls_config ) . clone ( ) )
600+ . build ( )
601+ . map_err ( |e| WSError :: InternalError ( format ! ( "Failed to create HTTP client: {}" , e) ) )
602+ }
603+
604+ async fn convert_response ( response : reqwest:: Response ) -> Result < HttpResponse , WSError > {
605+ let status = response. status ( ) . as_u16 ( ) ;
606+ let response_headers: HashMap < String , String > = response
607+ . headers ( )
608+ . iter ( )
609+ . map ( |( k, v) | ( k. to_string ( ) , v. to_str ( ) . unwrap_or ( "" ) . to_string ( ) ) )
610+ . collect ( ) ;
611+
612+ let body = response
613+ . bytes ( )
614+ . await
615+ . map_err ( |e| WSError :: InternalError ( format ! ( "Failed to read response body: {}" , e) ) ) ?;
616+
617+ Ok ( HttpResponse {
618+ status,
619+ body : body. to_vec ( ) ,
620+ headers : response_headers,
621+ } )
622+ }
623+ }
624+ }
625+
626+ // ============================================================================
627+ // WASM stubs
628+ // ============================================================================
629+
313630// WASM target placeholder - HTTP not available in WASM components
314631// WASM uses WASI HTTP which requires different handling (WASI 0.3 for async)
315632// For now, the crypto component doesn't need HTTP, so we provide a stub
0 commit comments