use affinidi_did_resolver_cache_sdk::DIDCacheClient;
use affinidi_messaging_didcomm::Message;
use affinidi_messaging_didcomm_service::DIDCommService;
use serde_json::Value;
use tracing::{debug, warn};
use crate::server::didcomm_profile::{PeerTransport, TransportFallback, resolve_send_binding};
use crate::server::trust_tasks::{TransportBoundVerifier, build_verifier, verify_sender_bound};
pub type SendError = Box<dyn std::error::Error + Send + Sync>;
pub async fn send_trust_task(
didcomm: &DIDCommService,
listener_id: &str,
from: &str,
to: &str,
doc: &trust_tasks_rs::TrustTask<Value>,
fallback: &TransportFallback,
did_resolver: Option<&DIDCacheClient>,
) -> Result<PeerTransport, SendError> {
match resolve_send_binding(to, fallback, did_resolver).await {
Some((PeerTransport::Tsp, _)) => {
let payload = crate::server::tsp_binding::frame(
serde_json::to_vec(doc)?,
crate::server::tsp_binding::Carriage::Envelope,
);
match didcomm.send_tsp(listener_id, to, &payload).await {
Ok(()) => {
debug!(to, type_uri = %doc.type_uri, "trust task sent over TSP");
Ok(PeerTransport::Tsp)
}
Err(tsp_err) => {
warn!(
to,
type_uri = %doc.type_uri,
error = %tsp_err,
"trust task: TSP send failed — falling back to DIDComm"
);
send_over_didcomm(didcomm, listener_id, from, to, doc).await?;
Ok(PeerTransport::Didcomm)
}
}
}
Some((PeerTransport::Didcomm, _)) => {
send_over_didcomm(didcomm, listener_id, from, to, doc).await?;
Ok(PeerTransport::Didcomm)
}
Some((PeerTransport::Https, endpoint)) => {
send_over_https(from, to, &endpoint, doc, did_resolver).await?;
debug!(to, endpoint, type_uri = %doc.type_uri, "trust task sent over HTTPS");
Ok(PeerTransport::Https)
}
None => Err(format!(
"no route to {to}: peer advertises no messaging transport and no mediator is configured"
)
.into()),
}
}
#[allow(clippy::too_many_arguments)]
pub async fn send_trust_task_with_retry(
didcomm: &DIDCommService,
listener_id: &str,
from: &str,
to: &str,
doc: &trust_tasks_rs::TrustTask<Value>,
fallback: &TransportFallback,
did_resolver: Option<&DIDCacheClient>,
retry: Retry,
) -> Result<PeerTransport, SendError> {
let attempts = retry.attempts.max(1);
let mut last: Option<SendError> = None;
for attempt in 1..=attempts {
match send_trust_task(didcomm, listener_id, from, to, doc, fallback, did_resolver).await {
Ok(t) => return Ok(t),
Err(e) => {
debug!(
to,
attempt,
attempts,
error = %e,
"trust task send failed — retrying"
);
last = Some(e);
if attempt < attempts {
tokio::time::sleep(retry.delay).await;
}
}
}
}
Err(last.unwrap_or_else(|| "send_trust_task_with_retry: no attempts made".into()))
}
#[derive(Debug, Clone, Copy)]
pub struct Retry {
pub attempts: u32,
pub delay: std::time::Duration,
}
async fn send_over_didcomm(
didcomm: &DIDCommService,
listener_id: &str,
from: &str,
to: &str,
doc: &trust_tasks_rs::TrustTask<Value>,
) -> Result<(), SendError> {
let body = serde_json::to_value(doc)?;
let msg = Message::build(
uuid::Uuid::new_v4().to_string(),
trust_tasks_didcomm::ENVELOPE_TYPE.to_string(),
body,
)
.from(from.to_string())
.to(to.to_string())
.created_time(crate::server::auth::session::now_epoch())
.finalize();
didcomm.send_message(listener_id, msg, to).await?;
debug!(to, type_uri = %doc.type_uri, "trust task sent over DIDComm envelope");
Ok(())
}
const HTTPS_MAX_REPLY_BYTES: u64 = 1024 * 1024;
async fn send_over_https(
from: &str,
to: &str,
endpoint: &str,
doc: &trust_tasks_rs::TrustTask<Value>,
did_resolver: Option<&DIDCacheClient>,
) -> Result<(), SendError> {
let url = format!("{}/trust-tasks", endpoint.trim_end_matches('/'));
post_trust_task_https(from, to, &url, doc, did_resolver)
.await
.map(|_| ())
}
pub async fn post_trust_task_https(
from: &str,
to: &str,
url: &str,
doc: &trust_tasks_rs::TrustTask<Value>,
did_resolver: Option<&DIDCacheClient>,
) -> Result<trust_tasks_rs::TrustTask<Value>, SendError> {
let verifier = build_verifier(did_resolver).ok_or_else(|| {
format!("trust task HTTPS reply from {to}: no DID resolver configured to verify it")
})?;
post_trust_task_https_verified(from, to, url, doc, &verifier).await
}
pub async fn post_trust_task_https_verified(
from: &str,
to: &str,
url: &str,
doc: &trust_tasks_rs::TrustTask<Value>,
verifier: &TransportBoundVerifier,
) -> Result<trust_tasks_rs::TrustTask<Value>, SendError> {
let resp = crate::http::outbound_client()
.post(url)
.json(doc)
.send()
.await?;
let status = resp.status();
let body = read_capped_body(resp, HTTPS_MAX_REPLY_BYTES).await?;
if !status.is_success() {
if let Some((code, message)) = unsigned_error_summary(&body) {
return Err(refused(to, url, &code, &message).into());
}
return Err(format!(
"trust task HTTPS send to {to} ({url}) failed: HTTP {status}: {}",
String::from_utf8_lossy(&body)
)
.into());
}
let reply: trust_tasks_rs::TrustTask<Value> = serde_json::from_slice(&body).map_err(|e| {
format!("trust task HTTPS reply from {to} ({url}) did not parse as a Trust Task: {e}")
})?;
let expected_thread = doc.thread_id.clone().unwrap_or_else(|| doc.id.clone());
if reply.thread_id.as_deref() != Some(expected_thread.as_str()) {
return Err(format!(
"trust task HTTPS reply from {to} is not threaded to the request: expected \
threadId {expected_thread}, got {:?}",
reply.thread_id
)
.into());
}
if let Err(e) = verify_sender_bound(&reply, Some(to), None, from, verifier).await {
if let Some((code, message)) = unsigned_error_summary(&body) {
return Err(refused(to, url, &code, &message).into());
}
return Err(e.into());
}
Ok(reply)
}
fn refused(to: &str, url: &str, code: &str, message: &str) -> String {
let code = crate::error::redact_server_message(code);
if message.is_empty() {
format!("trust task to {to} ({url}) was refused: {code}")
} else {
let message = crate::error::redact_server_message(message);
format!("trust task to {to} ({url}) was refused: {code}: {message}")
}
}
fn unsigned_error_summary(body: &[u8]) -> Option<(String, String)> {
let v: Value = serde_json::from_slice(body).ok()?;
let error_type = crate::server::trust_tasks::framework_error_type_uri().to_string();
let is_error = v
.get("type")
.and_then(Value::as_str)
.is_some_and(|t| t == error_type);
if !is_error || v.get("proof").is_some_and(|p| !p.is_null()) {
return None;
}
let payload = v.get("payload")?;
let code = payload.get("code").and_then(Value::as_str)?.to_string();
let message = payload
.get("message")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
Some((code, message))
}
async fn read_capped_body(mut resp: reqwest::Response, limit: u64) -> Result<Vec<u8>, SendError> {
if let Some(len) = resp.content_length()
&& len > limit
{
return Err(
format!("trust task HTTPS reply body too large: {len} bytes (limit {limit})").into(),
);
}
let mut body = Vec::new();
while let Some(chunk) = resp.chunk().await? {
body.extend_from_slice(&chunk);
if body.len() as u64 > limit {
return Err(
format!("trust task HTTPS reply body exceeded the {limit}-byte limit").into(),
);
}
}
Ok(body)
}
pub fn build_request(
type_uri: &str,
from: &str,
to: &str,
payload: Value,
) -> Result<trust_tasks_rs::TrustTask<Value>, SendError> {
let uri: trust_tasks_rs::TypeUri = type_uri.parse()?;
let mut doc = trust_tasks_rs::TrustTask::new(uuid::Uuid::new_v4().to_string(), uri, payload);
doc.issuer = Some(from.to_string());
doc.recipient = Some(to.to_string());
doc.issued_at = Some(chrono::Utc::now());
Ok(doc)
}
pub async fn build_signed_request(
type_uri: &str,
from: &str,
to: &str,
payload: Value,
signer: &affinidi_tdk::secrets_resolver::secrets::Secret,
) -> Result<trust_tasks_rs::TrustTask<Value>, SendError> {
let doc = build_request(type_uri, from, to, payload)?;
Ok(super::bound::sign_document(&doc, signer).await?)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::didcomm_types::{
MSG_HEALTH_PING, MSG_HEALTH_PONG, MSG_SERVER_REGISTER, MSG_SERVER_REGISTER_ACK,
};
#[test]
fn msg_constants_are_request_response_type_uri_pairs() {
for (req, resp) in [
(MSG_HEALTH_PING, MSG_HEALTH_PONG),
(MSG_SERVER_REGISTER, MSG_SERVER_REGISTER_ACK),
] {
let doc = build_request(req, "did:example:a", "did:example:b", Value::Null)
.unwrap_or_else(|e| panic!("{req} must parse as a TypeUri: {e}"));
let reply = doc.respond_with("reply-id", Value::Null);
assert_eq!(
reply.type_uri.to_string(),
resp,
"respond_with({req}) must produce {resp}"
);
}
}
#[test]
fn response_swaps_parties_and_threads_to_request() {
let doc = build_request(
MSG_HEALTH_PING,
"did:example:control",
"did:example:server",
serde_json::json!({}),
)
.expect("build");
let reply = doc.respond_with("reply-id", serde_json::json!({ "status": "ok" }));
assert_eq!(reply.issuer.as_deref(), Some("did:example:server"));
assert_eq!(reply.recipient.as_deref(), Some("did:example:control"));
assert_eq!(reply.thread_id.as_deref(), Some(doc.id.as_str()));
}
#[test]
fn unsigned_error_summary_reads_only_unsigned_framework_errors() {
let error_type = crate::server::trust_tasks::framework_error_type_uri().to_string();
let unsigned = serde_json::json!({
"type": error_type,
"payload": { "code": "permissionDenied", "message": "no standing" },
});
assert_eq!(
unsigned_error_summary(unsigned.to_string().as_bytes()),
Some(("permissionDenied".to_string(), "no standing".to_string()))
);
let signed = serde_json::json!({
"type": error_type,
"payload": { "code": "permissionDenied" },
"proof": { "proofValue": "zsomething" },
});
assert_eq!(unsigned_error_summary(signed.to_string().as_bytes()), None);
let ordinary = serde_json::json!({
"type": "https://trusttasks.org/spec/did-management/server/metrics/0.1#response",
"payload": { "snapshot": {} },
});
assert_eq!(
unsigned_error_summary(ordinary.to_string().as_bytes()),
None
);
}
#[test]
fn refused_sanitises_the_peer_supplied_fields() {
let msg = refused(
"did:x",
"http://h",
"permission\u{1b}Denied",
"a\u{1b}[2Jb\nc",
);
assert!(!msg.chars().any(char::is_control), "{msg:?}");
assert!(msg.ends_with("permission Denied: a [2Jb c"), "{msg:?}");
}
#[test]
fn route_decorator_uris_are_not_valid_document_type_uris() {
let decorator = "https://trusttasks.org/did-hosting/server/health-ping/1.0";
assert!(
build_request(decorator, "did:example:a", "did:example:b", Value::Null).is_err(),
"route-decorator URI must not parse as a document TypeUri"
);
}
}