#![cfg(all(feature = "signatures", feature = "tsa-client"))]
use crate::error::{Error, Result};
use crate::signatures::timestamp::HashAlgorithm;
use crate::signatures::Timestamp;
use cms::cert::x509::spki::AlgorithmIdentifier;
use der::asn1::OctetString;
use der::{Any, Decode, Encode};
use std::time::Duration;
use x509_tsp::{MessageImprint, TimeStampReq, TimeStampResp, TspVersion};
#[derive(Debug, Clone)]
pub struct TsaClientConfig {
pub url: String,
pub username: Option<String>,
pub password: Option<String>,
pub timeout: Duration,
pub hash_algorithm: HashAlgorithm,
pub use_nonce: bool,
pub cert_req: bool,
}
impl TsaClientConfig {
pub fn new(url: impl Into<String>) -> Self {
Self {
url: url.into(),
username: None,
password: None,
timeout: Duration::from_secs(30),
hash_algorithm: HashAlgorithm::Sha256,
use_nonce: true,
cert_req: true,
}
}
}
#[derive(Debug, Clone)]
pub struct TsaClient {
config: TsaClientConfig,
}
impl TsaClient {
pub fn new(config: TsaClientConfig) -> Self {
Self { config }
}
pub fn request_timestamp(&self, data: &[u8]) -> Result<Timestamp> {
let digest = self.digest(data);
self.request_timestamp_hash(&digest, self.config.hash_algorithm)
}
pub fn request_timestamp_hash(
&self,
hash: &[u8],
hash_algo: HashAlgorithm,
) -> Result<Timestamp> {
let req_bytes =
encode_request(hash, hash_algo, self.config.use_nonce, self.config.cert_req)?;
let resp_bytes = self.post(&req_bytes)?;
let resp = TimeStampResp::from_der(&resp_bytes).map_err(|e| {
Error::InvalidPdf(format!("TSA response is not a valid TimeStampResp: {e}"))
})?;
use cmpv2::status::PkiStatus;
match resp.status.status {
PkiStatus::Accepted | PkiStatus::GrantedWithMods => {},
other => {
let message = resp
.status
.fail_info
.map(|fi| format!("PKI fail-info: {fi:?}"))
.unwrap_or_else(|| format!("PKI status: {other:?}"));
return Err(Error::InvalidPdf(format!("TSA rejected request ({message})")));
},
}
let token = resp.time_stamp_token.ok_or_else(|| {
Error::InvalidPdf("TSA granted request but returned no timeStampToken".into())
})?;
let token_bytes = token
.to_der()
.map_err(|e| Error::InvalidPdf(format!("failed to re-encode TSA token: {e}")))?;
Timestamp::from_der(&token_bytes)
}
fn digest(&self, data: &[u8]) -> Vec<u8> {
super::crypto::hash_with_algorithm(self.config.hash_algorithm, data)
}
fn post(&self, body: &[u8]) -> Result<Vec<u8>> {
use std::io::Read;
let agent = ureq::Agent::config_builder()
.timeout_global(Some(self.config.timeout))
.build()
.new_agent();
let mut req = agent
.post(&self.config.url)
.header("Content-Type", "application/timestamp-query")
.header("Accept", "application/timestamp-reply");
if let (Some(u), Some(p)) = (&self.config.username, &self.config.password) {
use base64::Engine as _;
let creds =
base64::engine::general_purpose::STANDARD.encode(format!("{u}:{p}").as_bytes());
req = req.header("Authorization", &format!("Basic {creds}"));
}
let mut resp = req
.send(body)
.map_err(|e| Error::Io(std::io::Error::other(format!("TSA HTTP error: {e}"))))?;
let mut out = Vec::new();
resp.body_mut()
.as_reader()
.read_to_end(&mut out)
.map_err(|e| Error::Io(std::io::Error::other(format!("TSA read error: {e}"))))?;
Ok(out)
}
}
fn encode_request(
hash: &[u8],
hash_algo: HashAlgorithm,
use_nonce: bool,
cert_req: bool,
) -> Result<Vec<u8>> {
let oid = match super::crypto::oid_for_algorithm(hash_algo) {
Some(o) => o,
None => {
return Err(Error::InvalidPdf(
"cannot encode TimeStampReq with Unknown hash algorithm".into(),
));
},
};
let hash_algorithm = AlgorithmIdentifier::<Any> {
oid,
parameters: None,
};
let hashed_message = OctetString::new(hash)
.map_err(|e| Error::InvalidPdf(format!("invalid digest bytes: {e}")))?;
let nonce = if use_nonce {
Some(random_nonce())
} else {
None
};
let req = TimeStampReq {
version: TspVersion::V1,
message_imprint: MessageImprint {
hash_algorithm,
hashed_message,
},
req_policy: None,
nonce,
cert_req,
extensions: None,
};
req.to_der()
.map_err(|e| Error::InvalidPdf(format!("failed to encode TimeStampReq: {e}")))
}
fn random_nonce() -> der::asn1::Int {
let mut bytes = [0u8; 8];
getrandom::fill(&mut bytes).unwrap_or_else(|_| {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
bytes.copy_from_slice(&now.to_be_bytes());
});
bytes[0] &= 0x7F;
der::asn1::Int::new(&bytes).expect("8 positive bytes always fit in Int")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encode_request_roundtrips() {
let hash = [0xAB; 32]; let bytes = encode_request(&hash, HashAlgorithm::Sha256, true, true).unwrap();
let req = TimeStampReq::from_der(&bytes).unwrap();
assert_eq!(req.version, TspVersion::V1);
assert_eq!(req.message_imprint.hash_algorithm.oid, der::oid::db::rfc5912::ID_SHA_256);
assert_eq!(req.message_imprint.hashed_message.as_bytes(), hash);
assert!(req.nonce.is_some());
assert!(req.cert_req);
}
#[test]
fn encode_rejects_unknown_hash() {
let err = encode_request(&[0; 32], HashAlgorithm::Unknown, false, false).unwrap_err();
assert!(matches!(err, Error::InvalidPdf(_)), "expected InvalidPdf, got {err:?}");
}
#[test]
fn config_defaults_look_sane() {
let cfg = TsaClientConfig::new("https://freetsa.org/tsr");
assert_eq!(cfg.timeout, Duration::from_secs(30));
assert_eq!(cfg.hash_algorithm, HashAlgorithm::Sha256);
assert!(cfg.use_nonce);
assert!(cfg.cert_req);
assert!(cfg.username.is_none());
assert!(cfg.password.is_none());
}
}