Skip to content

Commit b7f8bbd

Browse files
authored
feat: add PinnedHttpClient with shared TLS config (#39)
Add certificate pinning support to the HTTP abstraction layer: - Extract create_pinned_rustls_config() as shared function for both sync (ureq) and async (reqwest) HTTP clients - Add PinnedHttpClient that enforces certificate pinning using the shared rustls config - Refactor transport.rs to use the shared TLS configuration - Update signer.rs to use check_pinning_requirement instead of deprecated check_pinning_enforcement - Clean up exports in keyless mod.rs - Fix cfg guards for WASM/WASI targets This enables consistent certificate pinning behavior across sync and async HTTP clients, preparing for async HTTP support when needed.
1 parent 3136249 commit b7f8bbd

File tree

6 files changed

+430
-52
lines changed

6 files changed

+430
-52
lines changed

src/lib/src/http/mod.rs

Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

src/lib/src/secure_file.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,13 @@
2525
//! ```
2626
2727
use crate::error::WSError;
28-
use std::fs::{self, File, OpenOptions};
28+
use std::fs::{File, OpenOptions};
2929
use std::io::{Read, Write};
3030
use std::path::Path;
3131

32+
#[cfg(unix)]
33+
use std::fs;
34+
3235
/// The restrictive permission mode for sensitive files (owner read/write only)
3336
#[cfg(unix)]
3437
pub const SECURE_FILE_MODE: u32 = 0o600;

0 commit comments

Comments
 (0)