use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use affinidi_messaging_core::{Inbound, MessageTransport};
use affinidi_messaging_delivery::{Delivery, InMemoryOutboxStore, MessagingService, OutboxStore};
use affinidi_tdk::common::TDKSharedState;
use affinidi_tdk::common::config::TDKConfig;
use affinidi_tdk::didcomm::Message;
use affinidi_tdk::messaging::config::ATMConfig;
use affinidi_tdk::messaging::profiles::ATMProfile;
use affinidi_tdk::messaging::{ATM, DidCommTransport};
use affinidi_tdk::secrets_resolver::SecretsResolver;
use futures_util::StreamExt;
use futures_util::stream::BoxStream;
use tracing::{debug, info, warn};
use crate::error::VtaError;
use crate::protocols::PROBLEM_REPORT_TYPE;
const MEDIATOR_OP_TIMEOUT: Duration = Duration::from_secs(15);
#[derive(Clone)]
pub struct DIDCommSession {
service: Arc<MessagingService>,
atm: Arc<ATM>,
subscriber: Arc<tokio::sync::Mutex<BoxStream<'static, Inbound>>>,
#[cfg(feature = "tsp")]
tsp: Arc<TspLeg>,
pub(crate) mediator_did: String,
pub(crate) client_did: String,
pub(crate) vta_did: String,
shutdown: Arc<AtomicBool>,
_leak_guard: Arc<LeakGuard>,
}
struct LeakGuard {
shutdown: Arc<AtomicBool>,
client_did: String,
vta_did: String,
}
impl LeakGuard {
fn leaked(&self) -> bool {
!self.shutdown.load(Ordering::Acquire)
}
}
impl Drop for LeakGuard {
fn drop(&mut self) {
if self.leaked() && !std::thread::panicking() {
warn!(
client_did = %self.client_did,
vta_did = %self.vta_did,
"DIDComm session dropped without shutdown() — a live, auto-reconnecting \
session leaked. Two sessions for the same DID fight on the mediator and \
round-trips time out. Call `client.shutdown().await`, or use \
`VtaClient::with_didcomm`."
);
debug_assert!(
false,
"DIDComm session for `{}` dropped without shutdown() — call \
shutdown().await or use VtaClient::with_didcomm",
self.client_did
);
}
}
}
#[cfg(feature = "tsp")]
struct TspLeg {
atm: Arc<ATM>,
profile: Arc<ATMProfile>,
demux: crate::tsp_demux::TspDemux,
stream: tokio::sync::Mutex<BoxStream<'static, Inbound>>,
}
#[cfg(feature = "tsp")]
enum TspPump {
Delivered,
Uncorrelated(String),
Idle,
}
#[cfg(feature = "tsp")]
const TSP_PUMP_SLICE: Duration = Duration::from_millis(250);
impl DIDCommSession {
pub fn client_did(&self) -> &str {
&self.client_did
}
pub fn mediator_did(&self) -> &str {
&self.mediator_did
}
pub async fn connect(
client_did: &str,
private_key_multibase: &str,
vta_did: &str,
mediator_did: &str,
) -> Result<Self, Box<dyn std::error::Error>> {
let seed = crate::did_key::decode_private_key_multibase(private_key_multibase)?;
let secrets = crate::did_key::secrets_from_did_key(client_did, &seed)?;
Self::connect_with_secrets(
client_did,
vec![secrets.signing, secrets.key_agreement],
vta_did,
mediator_did,
)
.await
}
pub async fn connect_with_secrets(
client_did: &str,
secrets: Vec<affinidi_tdk::secrets_resolver::secrets::Secret>,
vta_did: &str,
mediator_did: &str,
) -> Result<Self, Box<dyn std::error::Error>> {
let tdk = TDKSharedState::new(TDKConfig::builder().build()?).await?;
for secret in secrets {
tdk.secrets_resolver().insert(secret).await;
}
let atm_config = ATMConfig::builder().build()?;
let atm = Arc::new(ATM::new(atm_config, Arc::new(tdk)).await?);
let outcome = Self::finish_connect(&atm, client_did, vta_did, mediator_did)
.await
.map_err(|e| e.to_string());
match outcome {
Ok(session) => Ok(session),
Err(msg) => {
atm.graceful_shutdown().await;
Err(msg.into())
}
}
}
async fn finish_connect(
atm: &Arc<ATM>,
client_did: &str,
vta_did: &str,
mediator_did: &str,
) -> Result<Self, Box<dyn std::error::Error>> {
let profile = Arc::new(
ATMProfile::new(
atm,
None,
client_did.to_string(),
Some(mediator_did.to_string()),
)
.await?,
);
{
use affinidi_tdk::messaging::messages::Folder;
match tokio::time::timeout(
MEDIATOR_OP_TIMEOUT,
atm.list_messages(&profile, Folder::Inbox),
)
.await
{
Ok(Ok(messages)) if !messages.is_empty() => {
let ids: Vec<String> = messages.iter().map(|m| m.msg_id.clone()).collect();
info!(
count = ids.len(),
"flushing stale queued messages from inbox"
);
let delete_req = affinidi_tdk::messaging::messages::DeleteMessageRequest {
message_ids: ids,
};
match tokio::time::timeout(
MEDIATOR_OP_TIMEOUT,
atm.delete_messages_direct(&profile, &delete_req),
)
.await
{
Ok(Ok(resp)) => {
debug!(
deleted = resp.success.len(),
errors = resp.errors.len(),
"inbox flushed"
);
}
Ok(Err(e)) => warn!("failed to flush stale messages (non-fatal): {e}"),
Err(_) => warn!(
"timeout flushing stale messages after {}s (non-fatal)",
MEDIATOR_OP_TIMEOUT.as_secs()
),
}
}
Ok(Ok(_)) => {} Ok(Err(e)) => warn!("could not list inbox (non-fatal): {e}"),
Err(_) => warn!(
"timeout listing inbox after {}s (non-fatal)",
MEDIATOR_OP_TIMEOUT.as_secs()
),
}
}
match tokio::time::timeout(MEDIATOR_OP_TIMEOUT, atm.profile_enable_websocket(&profile))
.await
{
Ok(res) => res?,
Err(_) => {
return Err(format!(
"timeout enabling WebSocket to mediator after {}s — \
mediator may be unreachable",
MEDIATOR_OP_TIMEOUT.as_secs()
)
.into());
}
}
#[cfg(feature = "acl-setup")]
crate::acl_setup::set_client_acl_on_connection(
atm,
client_did,
mediator_did,
"didcomm-session",
"pnm",
)
.await;
let transport: Arc<dyn MessageTransport> = Arc::new(
DidCommTransport::new((**atm).clone(), profile.clone())
.await
.map_err(|e| format!("bind DidComm transport: {e}"))?,
);
let outbox: Arc<dyn OutboxStore> = Arc::new(InMemoryOutboxStore::new());
let service = Arc::new(MessagingService::new(transport, outbox));
let subscriber = Arc::new(tokio::sync::Mutex::new(service.subscribe()));
#[cfg(feature = "tsp")]
let tsp = Arc::new(TspLeg {
atm: Arc::clone(atm),
profile: Arc::clone(&profile),
demux: crate::tsp_demux::TspDemux::new(),
stream: tokio::sync::Mutex::new(service.subscribe()),
});
debug!("DIDComm session connected via mediator {mediator_did} (delivery-layer mode)");
let shutdown = Arc::new(AtomicBool::new(false));
let leak_guard = Arc::new(LeakGuard {
shutdown: Arc::clone(&shutdown),
client_did: client_did.to_string(),
vta_did: vta_did.to_string(),
});
Ok(Self {
service,
atm: Arc::clone(atm),
subscriber,
#[cfg(feature = "tsp")]
tsp,
mediator_did: mediator_did.to_string(),
client_did: client_did.to_string(),
vta_did: vta_did.to_string(),
shutdown,
_leak_guard: leak_guard,
})
}
pub(crate) async fn seal_to_vta(&self, body: serde_json::Value) -> Result<String, VtaError> {
const VAULT_SECRET_TYPE: &str =
"https://trusttasks.org/spec/vault/_shared/0.1/vault-secret";
let msg_id = uuid::Uuid::new_v4().to_string();
let msg = Message::build(msg_id, VAULT_SECRET_TYPE.to_string(), body)
.from(self.client_did.clone())
.to(self.vta_did.clone())
.finalize();
let (packed, _) = self
.atm
.pack_encrypted(
&msg,
&self.vta_did,
Some(&self.client_did),
Some(&self.client_did),
)
.await
.map_err(|e| VtaError::DidcommTransport(format!("failed to seal vault secret: {e}")))?;
Ok(packed)
}
pub(crate) async fn open_from_vta(&self, jwe: &str) -> Result<serde_json::Value, VtaError> {
let (msg, _meta) = self.atm.unpack(jwe).await.map_err(|e| {
VtaError::DidcommTransport(format!("failed to open sealed secret: {e}"))
})?;
Ok(msg.body)
}
async fn pack_message(
&self,
recipient_did: &str,
msg_id: &str,
msg_type: &str,
body: serde_json::Value,
) -> Result<String, VtaError> {
let msg = Message::build(msg_id.to_string(), msg_type.to_string(), body)
.from(self.client_did.clone())
.to(recipient_did.to_string())
.finalize();
let (packed, _) = self
.atm
.pack_encrypted(
&msg,
recipient_did,
Some(&self.client_did),
Some(&self.client_did),
)
.await
.map_err(|e| VtaError::DidcommTransport(format!("failed to pack message: {e}")))?;
debug!(msg_type, msg_id, recipient_did, "packed DIDComm message");
Ok(packed)
}
pub async fn send_one_way(
&self,
recipient_did: &str,
msg_type: &str,
body: serde_json::Value,
) -> Result<(), VtaError> {
let msg_id = uuid::Uuid::new_v4().to_string();
let packed = self
.pack_message(recipient_did, &msg_id, msg_type, body)
.await?;
self.service
.send(recipient_did, packed.into_bytes(), Delivery::BestEffort)
.await
.map_err(|e| VtaError::DidcommTransport(format!("failed to send message: {e}")))?;
Ok(())
}
pub async fn send_and_wait<T: serde::de::DeserializeOwned>(
&self,
msg_type: &str,
body: serde_json::Value,
expected_result_type: &str,
timeout_secs: u64,
) -> Result<T, VtaError> {
let msg_id = uuid::Uuid::new_v4().to_string();
let packed = self
.pack_message(&self.vta_did, &msg_id, msg_type, body)
.await?;
let received = self
.service
.request(
&self.vta_did,
packed.into_bytes(),
&msg_id,
Duration::from_secs(timeout_secs),
)
.await
.map_err(|e| {
if e.to_string().contains("timed out") {
VtaError::DidcommTransport("timeout waiting for DIDComm response".into())
} else {
VtaError::DidcommTransport(format!("message pickup error: {e}"))
}
})?;
let response_msg: Message =
serde_json::from_slice(&received.payload).map_err(VtaError::from)?;
debug!(response_type = %response_msg.typ, "received DIDComm response");
if response_msg.typ == PROBLEM_REPORT_TYPE || response_msg.typ.contains("problem-report") {
let code = response_msg
.body
.get("code")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let comment = response_msg
.body
.get("comment")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
return Err(VtaError::from_problem_report(code, comment));
}
if response_msg.typ != expected_result_type {
return Err(VtaError::Protocol(format!(
"unexpected response type: expected {expected_result_type}, got {}",
response_msg.typ
)));
}
serde_json::from_value(response_msg.body).map_err(VtaError::from)
}
pub async fn receive_next(&self, timeout_secs: u64) -> Result<Option<String>, VtaError> {
let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs);
let mut stream = self.subscriber.lock().await;
#[cfg_attr(not(feature = "tsp"), allow(clippy::never_loop))]
loop {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
return Ok(None);
}
match tokio::time::timeout(remaining, stream.next()).await {
Ok(Some(inbound)) => {
#[cfg(feature = "tsp")]
if inbound.message.protocol == affinidi_messaging_core::Protocol::TSP {
debug!(
"skipping an inbound TSP frame on the DIDComm inbox (the TSP leg has it)"
);
continue;
}
let msg: Message =
serde_json::from_slice(&inbound.message.payload).map_err(VtaError::from)?;
debug!(msg_type = %msg.typ, "received inbound DIDComm message");
let json = serde_json::to_string(&msg).map_err(VtaError::from)?;
return Ok(Some(json));
}
Ok(None) => {
return if self.shutdown.load(Ordering::Acquire) {
Ok(None)
} else {
Err(VtaError::DidcommTransport(
"mediator inbound stream ended — session is no longer live".into(),
))
};
}
Err(_) => return Ok(None),
}
}
}
pub async fn shutdown(&self) {
self.shutdown.store(true, Ordering::Release);
#[cfg(feature = "tsp")]
self.tsp.demux.clear().await;
self.atm.graceful_shutdown().await;
}
}
#[cfg(feature = "tsp")]
impl DIDCommSession {
pub async fn send_tsp_document(
&self,
recipient_did: &str,
body: &[u8],
) -> Result<(), VtaError> {
self.tsp
.atm
.tsp()
.send_routed(
&self.tsp.profile,
&[self.mediator_did.clone(), recipient_did.to_string()],
body,
)
.await
.map_err(|e| VtaError::TspTransport(format!("TSP send failed: {e}")))
}
pub async fn request_tsp(
&self,
recipient_did: &str,
document: &[u8],
timeout: Duration,
) -> Result<String, VtaError> {
let (request_id, nonce) =
crate::tsp_demux::TspDemux::request_keys(document).map_err(VtaError::TspTransport)?;
let mut rx = self.tsp.demux.register(request_id.clone(), nonce).await;
let sent = self.send_tsp_document(recipient_did, document).await;
if sent.is_err() {
self.tsp.demux.deregister(&request_id).await;
}
sent?;
let deadline = tokio::time::Instant::now() + timeout;
let outcome = self.await_tsp_reply(&request_id, &mut rx, deadline).await;
self.tsp.demux.deregister(&request_id).await;
outcome.map_err(VtaError::TspTransport)
}
pub async fn receive_next_tsp(&self, timeout_secs: u64) -> Result<Option<String>, VtaError> {
let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs);
loop {
if let Some(doc) = self.tsp.demux.take_parked().await {
return Ok(Some(doc));
}
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
return Ok(None);
}
let mut guard = self.tsp.stream.lock().await;
let slice = TSP_PUMP_SLICE.min(remaining);
let outcome = self
.tsp_pump_once(&mut guard, slice)
.await
.map_err(VtaError::TspTransport)?;
match outcome {
TspPump::Uncorrelated(doc) => return Ok(Some(doc)),
TspPump::Delivered | TspPump::Idle => {
drop(guard);
continue;
}
}
}
}
async fn await_tsp_reply(
&self,
request_id: &str,
rx: &mut tokio::sync::oneshot::Receiver<String>,
deadline: tokio::time::Instant,
) -> Result<String, String> {
loop {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
return Err(format!(
"timed out waiting for the TSP reply to request '{request_id}'"
));
}
tokio::select! {
biased;
delivered = &mut *rx => {
return delivered.map_err(|_| {
"TSP session shut down while waiting for a reply".to_string()
});
}
mut guard = self.tsp.stream.lock() => {
let slice = TSP_PUMP_SLICE.min(remaining);
let outcome = self.tsp_pump_once(&mut guard, slice).await?;
match outcome {
TspPump::Uncorrelated(doc) => {
self.tsp.demux.park(doc).await;
}
TspPump::Delivered | TspPump::Idle => {}
}
drop(guard);
}
() = tokio::time::sleep(remaining) => {
return Err(format!(
"timed out waiting for the TSP reply to request '{request_id}'"
));
}
}
}
}
async fn tsp_pump_once(
&self,
stream: &mut BoxStream<'static, Inbound>,
slice: Duration,
) -> Result<TspPump, String> {
let until = tokio::time::Instant::now() + slice;
loop {
let remaining = until.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
return Ok(TspPump::Idle);
}
let inbound = match tokio::time::timeout(remaining, stream.next()).await {
Ok(Some(inbound)) => inbound,
Ok(None) => {
return Err(if self.shutdown.load(Ordering::Acquire) {
"TSP leg closed: the session was shut down".to_string()
} else {
"mediator inbound stream ended — session is no longer live".to_string()
});
}
Err(_) => return Ok(TspPump::Idle),
};
if inbound.message.protocol != affinidi_messaging_core::Protocol::TSP {
continue; }
let json = String::from_utf8(inbound.message.payload)
.map_err(|e| format!("TSP payload was not UTF-8: {e}"))?;
return Ok(match self.tsp.demux.route(json).await {
crate::tsp_demux::Routed::Delivered => TspPump::Delivered,
crate::tsp_demux::Routed::Uncorrelated(doc) => TspPump::Uncorrelated(doc),
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn guard(shutdown: &Arc<AtomicBool>) -> LeakGuard {
LeakGuard {
shutdown: Arc::clone(shutdown),
client_did: "did:key:zClient".into(),
vta_did: "did:key:zVta".into(),
}
}
#[test]
fn leak_guard_reports_a_leak_until_shutdown_is_marked() {
let shutdown = Arc::new(AtomicBool::new(false));
let g = guard(&shutdown);
assert!(g.leaked(), "an un-shut-down session is a leak");
shutdown.store(true, Ordering::Release);
assert!(!g.leaked(), "after shutdown() the session is not a leak");
drop(g);
}
#[test]
#[should_panic(expected = "dropped without shutdown()")]
fn dropping_a_leaked_guard_trips_the_debug_assert() {
if cfg!(debug_assertions) {
let shutdown = Arc::new(AtomicBool::new(false));
let _g = guard(&shutdown); } else {
panic!("dropped without shutdown() (release no-op shim)");
}
}
}
#[cfg(all(test, feature = "tsp"))]
mod tsp_leg_send_assertions {
use std::time::Duration;
fn assert_send<T: Send>(_: T) {}
#[allow(dead_code)]
fn tsp_leg_futures_are_send(s: &super::DIDCommSession) {
assert_send(s.request_tsp("did:vta", b"{}", Duration::from_secs(1)));
assert_send(s.receive_next_tsp(1));
assert_send(s.send_tsp_document("did:vta", b"{}"));
assert_send(s.receive_next(1));
assert_send(s.shutdown());
}
}