use std::time::Duration;
use super::token;
use crate::crypto::algorithm::DigestAlgorithm;
use crate::error::TspError;
use crate::net::AttestedHttpClient;
const TSP_REQUEST_CONTENT_TYPE: &str = "application/timestamp-query";
const TSP_RESPONSE_CONTENT_TYPE: &str = "application/timestamp-reply";
#[derive(Debug, Clone)]
pub struct TsaClient {
url: String,
http_client: AttestedHttpClient,
digest_algorithm: DigestAlgorithm,
policy_oid: Option<const_oid::ObjectIdentifier>,
timeout: Duration,
cert_req: bool,
verification_certs: Vec<x509_cert::Certificate>,
}
impl TsaClient {
pub fn new(url: &str) -> Result<Self, TspError> {
Ok(Self {
url: url.to_string(),
http_client: crate::net::hardened_http_client()?,
digest_algorithm: DigestAlgorithm::Sha256,
policy_oid: None,
timeout: Duration::from_secs(30),
cert_req: true,
verification_certs: Vec::new(),
})
}
pub fn digest_algorithm(mut self, alg: DigestAlgorithm) -> Self {
self.digest_algorithm = alg;
self
}
pub fn policy_oid(mut self, oid: const_oid::ObjectIdentifier) -> Self {
self.policy_oid = Some(oid);
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn cert_req(mut self, cert_req: bool) -> Self {
self.cert_req = cert_req;
self
}
pub fn verification_certs(mut self, certs: Vec<x509_cert::Certificate>) -> Self {
self.verification_certs = certs;
self
}
pub fn http_client(mut self, client: AttestedHttpClient) -> Self {
self.http_client = client;
self
}
#[cfg(not(feature = "fips"))]
pub fn unverified_http_client(mut self, client: reqwest::Client) -> Result<Self, TspError> {
self.http_client = crate::net::unverified_http_client(client)?;
Ok(self)
}
pub fn url(&self) -> &str {
&self.url
}
pub async fn timestamp(&self, data_hash: &[u8]) -> Result<Vec<u8>, TspError> {
match tokio::time::timeout(self.timeout, crate::net::validate_fetch_url(&self.url)).await {
Ok(result) => result.map_err(|e| TspError::HttpError(format!("URL rejected: {e}")))?,
Err(_) => {
return Err(TspError::HttpError(format!(
"URL validation for {} timed out after {:?}",
self.url, self.timeout
)))
}
}
let nonce = token::generate_nonce()?;
let req_der = token::build_timestamp_request(
self.digest_algorithm,
data_hash,
self.policy_oid.as_ref(),
Some(nonce),
self.cert_req,
)?;
log::debug!(
"Sending TimeStampReq to {} ({} bytes, nonce={})",
self.url,
req_der.len(),
nonce,
);
let response = self
.http_client
.client()
.post(&self.url)
.header("Content-Type", TSP_REQUEST_CONTENT_TYPE)
.timeout(self.timeout)
.body(req_der)
.send()
.await
.map_err(|e| TspError::HttpError(format!("TSA request to {} failed: {e}", self.url)))?;
let status = response.status();
if !status.is_success() {
return Err(TspError::HttpError(format!(
"TSA {} returned HTTP {status}",
self.url,
)));
}
if let Some(ct) = response.headers().get("content-type") {
let ct_str = ct.to_str().unwrap_or("");
if !ct_str.starts_with(TSP_RESPONSE_CONTENT_TYPE)
&& !ct_str.starts_with("application/timestamp-response")
{
log::warn!(
"TSA {} returned unexpected Content-Type: {ct_str}",
self.url,
);
}
}
let resp_bytes = response
.bytes()
.await
.map_err(|e| TspError::HttpError(format!("failed to read TSA response body: {e}")))?;
log::debug!(
"Received TimeStampResp from {} ({} bytes)",
self.url,
resp_bytes.len(),
);
let resp = token::parse_timestamp_response(&resp_bytes)?;
let token_der = token::validate_timestamp_response(
&resp,
data_hash,
Some(nonce),
self.digest_algorithm,
&self.verification_certs,
)?;
log::debug!(
"Timestamp token obtained from {} ({} bytes)",
self.url,
token_der.len(),
);
Ok(token_der)
}
#[cfg(feature = "blocking")]
pub fn timestamp_blocking(&self, data_hash: &[u8]) -> Result<Vec<u8>, TspError> {
tokio::runtime::Handle::current().block_on(self.timestamp(data_hash))
}
}
#[derive(Debug, Clone)]
pub struct TsaClientPool {
clients: Vec<TsaClient>,
}
impl TsaClientPool {
pub fn new(clients: Vec<TsaClient>) -> Self {
assert!(
!clients.is_empty(),
"TsaClientPool requires at least one client"
);
Self { clients }
}
pub fn from_url(url: &str) -> Result<Self, TspError> {
Ok(Self::new(vec![TsaClient::new(url)?]))
}
pub fn from_urls(urls: &[&str]) -> Result<Self, TspError> {
let clients = urls
.iter()
.map(|u| TsaClient::new(u))
.collect::<Result<Vec<_>, _>>()?;
Ok(Self::new(clients))
}
pub async fn timestamp(&self, data_hash: &[u8]) -> Result<Vec<u8>, TspError> {
let mut last_error = None;
for (i, client) in self.clients.iter().enumerate() {
match client.timestamp(data_hash).await {
Ok(token) => return Ok(token),
Err(e) => {
log::warn!(
"TSA {} ({}) failed: {e}; trying next ({}/{} remaining)",
client.url(),
i + 1,
self.clients.len() - i - 1,
self.clients.len(),
);
last_error = Some(e);
}
}
}
Err(last_error.unwrap_or_else(|| TspError::HttpError("no TSA clients configured".into())))
}
#[cfg(feature = "blocking")]
pub fn timestamp_blocking(&self, data_hash: &[u8]) -> Result<Vec<u8>, TspError> {
tokio::runtime::Handle::current().block_on(self.timestamp(data_hash))
}
pub fn len(&self) -> usize {
self.clients.len()
}
pub fn is_empty(&self) -> bool {
self.clients.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tsa_client_default() {
let client = TsaClient::new("http://timestamp.example.com").unwrap();
assert_eq!(client.url(), "http://timestamp.example.com");
assert_eq!(client.timeout, Duration::from_secs(30));
assert!(client.cert_req);
}
#[tokio::test]
async fn test_timestamp_rejects_loopback_url() {
let client = TsaClient::new("http://127.0.0.1/tsa").unwrap();
let data_hash = DigestAlgorithm::Sha256.digest(b"hello").unwrap();
let err = client
.timestamp(&data_hash)
.await
.expect_err("loopback TSA URL must be rejected");
let msg = format!("{err}");
assert!(
msg.contains("non-public") || msg.contains("SSRF"),
"expected SSRF rejection, got: {msg}"
);
}
}