use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use affinidi_did_resolver_cache_sdk::{DIDCacheClient, config::DIDCacheConfigBuilder};
use affinidi_tdk::didcomm::Message;
use affinidi_tdk::secrets_resolver::SecretsResolver;
use serde::{Deserialize, Serialize};
use serde_json;
use tracing::debug;
use crate::credentials::CredentialBundle;
use crate::protocols::auth::{AuthenticateResponse, ChallengeRequest, ChallengeResponse};
#[cfg(any(test, feature = "test-support"))]
pub mod testing;
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Session {
client_did: String,
private_key: String,
#[serde(default)]
vta_did: Option<String>,
access_token: Option<String>,
access_expires_at: Option<u64>,
#[serde(default)]
needs_rotation: bool,
}
fn require_vta_did(session: &Session) -> Result<&str, Box<dyn std::error::Error>> {
session.vta_did.as_deref().ok_or_else(|| {
"session is pending VTA binding — run `pnm setup continue <slug>` to supply the VTA DID"
.into()
})
}
#[derive(Clone)]
pub struct SessionInfo {
pub client_did: String,
pub vta_did: Option<String>,
pub private_key_multibase: String,
}
impl std::fmt::Debug for SessionInfo {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SessionInfo")
.field("client_did", &self.client_did)
.field("vta_did", &self.vta_did)
.field("private_key_multibase", &"<redacted>")
.finish()
}
}
#[derive(Debug, Clone)]
pub struct SessionStatus {
pub client_did: String,
pub vta_did: Option<String>,
pub token_status: TokenStatus,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TokenStatus {
Valid { expires_in_secs: u64 },
Expired,
None,
}
#[derive(Debug, Clone)]
pub struct LoginResult {
pub client_did: String,
pub vta_did: Option<String>,
}
#[derive(Clone)]
pub struct TokenResult {
pub access_token: String,
pub access_expires_at: u64,
}
impl std::fmt::Debug for TokenResult {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TokenResult")
.field("access_token", &"<redacted>")
.field("access_expires_at", &self.access_expires_at)
.finish()
}
}
pub trait SessionBackend: Send + Sync {
fn load(&self, key: &str) -> Option<String>;
fn save(&self, key: &str, value: &str) -> Result<(), Box<dyn std::error::Error>>;
fn clear(&self, key: &str);
}
mod backends;
use backends::default_backend;
pub struct SessionStore {
backend: Box<dyn SessionBackend>,
}
impl SessionStore {
pub fn new(service_name: &str, sessions_dir: PathBuf) -> Self {
Self {
backend: default_backend(service_name, sessions_dir),
}
}
pub fn with_backend(backend: Box<dyn SessionBackend>) -> Self {
Self { backend }
}
fn load_session(&self, key: &str) -> Option<Session> {
let json = self.backend.load(key)?;
serde_json::from_str(&json).ok()
}
fn save_session(&self, key: &str, session: &Session) -> Result<(), Box<dyn std::error::Error>> {
let json = serde_json::to_string(session)?;
self.backend.save(key, &json)
}
fn clear_session(&self, key: &str) {
self.backend.clear(key);
}
pub fn has_session(&self, key: &str) -> bool {
self.load_session(key).is_some()
}
pub async fn login(
&self,
bundle: &CredentialBundle,
base_url: &str,
key: &str,
) -> Result<LoginResult, Box<dyn std::error::Error>> {
debug!(
client_did = %bundle.did,
vta_did = %bundle.vta_did,
"login with credential bundle"
);
let mut session = Session {
client_did: bundle.did.clone(),
private_key: bundle.private_key_multibase.clone(),
vta_did: Some(bundle.vta_did.clone()),
access_token: None,
access_expires_at: None,
needs_rotation: false,
};
self.save_session(key, &session)?;
debug!(keyring_key = key, "session saved");
let token = challenge_response(
base_url,
&bundle.did,
&bundle.private_key_multibase,
&bundle.vta_did,
)
.await?;
session.access_token = Some(token.access_token);
session.access_expires_at = Some(token.access_expires_at);
self.save_session(key, &session)?;
Ok(LoginResult {
client_did: bundle.did.clone(),
vta_did: Some(bundle.vta_did.clone()),
})
}
pub fn store_direct(
&self,
key: &str,
did: &str,
private_key: &str,
vta_did: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let session = Session {
client_did: did.to_string(),
private_key: private_key.to_string(),
vta_did: Some(vta_did.to_string()),
access_token: None,
access_expires_at: None,
needs_rotation: false,
};
self.save_session(key, &session)
}
pub fn store_pending_rotation(
&self,
key: &str,
did: &str,
private_key: &str,
vta_did: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let session = Session {
client_did: did.to_string(),
private_key: private_key.to_string(),
vta_did: Some(vta_did.to_string()),
access_token: None,
access_expires_at: None,
needs_rotation: true,
};
self.save_session(key, &session)
}
pub fn store_pending_vta_binding(
&self,
key: &str,
did: &str,
private_key: &str,
) -> Result<(), Box<dyn std::error::Error>> {
if key.trim().is_empty() {
return Err("keyring key must be non-empty".into());
}
if !did.starts_with("did:key:") {
return Err(
"pending ephemeral DID must be a did:key (minted locally by the SDK)".into(),
);
}
if private_key.trim().is_empty() {
return Err("private key multibase must be non-empty".into());
}
let session = Session {
client_did: did.to_string(),
private_key: private_key.to_string(),
vta_did: None,
access_token: None,
access_expires_at: None,
needs_rotation: false,
};
self.save_session(key, &session)
}
pub fn bind_vta_did(&self, key: &str, vta_did: &str) -> Result<(), Box<dyn std::error::Error>> {
let vta_did = vta_did.trim();
if vta_did.is_empty() {
return Err("VTA DID must be non-empty".into());
}
if !vta_did.starts_with("did:") {
return Err(
"VTA DID must start with `did:` (e.g. did:webvh:..., did:web:..., did:key:...)"
.into(),
);
}
let mut session = self
.load_session(key)
.ok_or("no session found — cannot bind VTA DID to a non-existent entry")?;
if session.vta_did.is_some() {
return Err("session already has a VTA DID bound".into());
}
session.vta_did = Some(vta_did.to_string());
session.needs_rotation = true;
self.save_session(key, &session)
}
pub fn has_pending_vta_binding(&self, key: &str) -> bool {
match self.load_session(key) {
Some(session) => session.vta_did.is_none(),
None => false,
}
}
pub fn logout(&self, key: &str) {
self.clear_session(key);
}
pub fn loaded_session(&self, key: &str) -> Option<SessionInfo> {
self.load_session(key).map(|s| SessionInfo {
client_did: s.client_did,
vta_did: s.vta_did,
private_key_multibase: s.private_key,
})
}
pub fn session_status(&self, key: &str) -> Option<SessionStatus> {
let session = self.load_session(key)?;
let token_status = match (session.access_token, session.access_expires_at) {
(Some(_), Some(exp)) => {
let now = now_epoch();
if exp > now {
TokenStatus::Valid {
expires_in_secs: exp - now,
}
} else {
TokenStatus::Expired
}
}
_ => TokenStatus::None,
};
Some(SessionStatus {
client_did: session.client_did,
vta_did: session.vta_did,
token_status,
})
}
pub async fn ensure_authenticated(
&self,
base_url: &str,
key: &str,
) -> Result<String, Box<dyn std::error::Error>> {
debug!(base_url, keyring_key = key, "ensuring authentication");
let mut session = self.load_session(key).ok_or(
"Not authenticated.\n\nRun `pnm setup` (or the equivalent) to provision an admin identity.",
)?;
let session_vta_did = require_vta_did(&session)?.to_string();
debug!(
client_did = %session.client_did,
vta_did = %session_vta_did,
needs_rotation = session.needs_rotation,
"session loaded"
);
if !session.needs_rotation
&& let (Some(token), Some(expires_at)) =
(&session.access_token, session.access_expires_at)
&& now_epoch() + 30 < expires_at
{
debug!(expires_in = expires_at - now_epoch(), "using cached token");
return Ok(token.clone());
}
debug!("cached token expired or missing, performing challenge-response");
let result = challenge_response(
base_url,
&session.client_did,
&session.private_key,
&session_vta_did,
)
.await?;
if session.needs_rotation {
debug!("session is pending rotation, swapping to fresh did:key");
let (new_session, new_token_result) =
rotate_key(base_url, session, &result.access_token).await?;
session = new_session;
session.access_token = Some(new_token_result.access_token.clone());
session.access_expires_at = Some(new_token_result.access_expires_at);
self.save_session(key, &session)?;
return Ok(new_token_result.access_token);
}
let token = result.access_token.clone();
session.access_token = Some(result.access_token);
session.access_expires_at = Some(result.access_expires_at);
self.save_session(key, &session)?;
debug!("new token cached");
Ok(token)
}
#[cfg(feature = "session")]
pub async fn ensure_authenticated_didcomm(
&self,
key: &str,
) -> Result<crate::client::VtaClient, Box<dyn std::error::Error>> {
let session = self.load_session(key).ok_or(
"Not authenticated.\n\nRun `pnm setup` (or the equivalent) to provision an admin identity.",
)?;
let session_vta_did = require_vta_did(&session)?.to_string();
debug!(
client_did = %session.client_did,
vta_did = %session_vta_did,
needs_rotation = session.needs_rotation,
"ensure_authenticated_didcomm: session loaded"
);
let (vta_did, mediator_did, rest_url) = match resolve_vta_endpoint(&session_vta_did).await?
{
VtaEndpoint::DIDComm {
vta_did,
mediator_did,
rest_url,
} => (vta_did, mediator_did, rest_url),
VtaEndpoint::Rest { .. } => {
return Err(format!(
"VTA '{session_vta_did}' does not advertise a DIDComm service endpoint. \
Use SessionStore::ensure_authenticated for REST, or \
SessionStore::connect to auto-select."
)
.into());
}
};
let client = crate::client::VtaClient::connect_didcomm(
&session.client_did,
&session.private_key,
&vta_did,
&mediator_did,
rest_url.clone(),
)
.await?;
if !session.needs_rotation {
return Ok(client);
}
debug!("session is pending rotation, swapping to fresh did:key over DIDComm");
let rotated =
rotate_key_didcomm(&client, &session, &vta_did, &mediator_did, rest_url.clone())
.await?;
client.shutdown().await;
self.save_session(key, &rotated)?;
let new_client = crate::client::VtaClient::connect_didcomm(
&rotated.client_did,
&rotated.private_key,
&vta_did,
&mediator_did,
rest_url,
)
.await?;
Ok(new_client)
}
pub async fn connect(
&self,
key: &str,
url_override: Option<&str>,
) -> Result<crate::client::VtaClient, Box<dyn std::error::Error>> {
let session = self.load_session(key).ok_or(
"Not authenticated.\n\nTo authenticate, import a credential:\n <cli> auth login <credential-string>",
)?;
if let Some(url) = url_override {
debug!(url, "using REST transport (URL override)");
let token = self.ensure_authenticated(url, key).await?;
let client = crate::client::VtaClient::new(url);
client.set_token(token);
return Ok(client);
}
let session_vta_did = require_vta_did(&session)?.to_string();
match resolve_vta_endpoint(&session_vta_did).await? {
VtaEndpoint::DIDComm {
vta_did,
mediator_did,
rest_url,
} => {
debug!("connecting via DIDComm");
let client = crate::client::VtaClient::connect_didcomm(
&session.client_did,
&session.private_key,
&vta_did,
&mediator_did,
rest_url,
)
.await?;
Ok(client)
}
VtaEndpoint::Rest { url } => {
debug!(url = %url, "connecting via REST");
let token = self.ensure_authenticated(&url, key).await?;
let client = crate::client::VtaClient::new(&url);
client.set_token(token);
Ok(client)
}
}
}
}
#[cfg(feature = "session")]
async fn rotate_key_didcomm(
client: &crate::client::VtaClient,
session: &Session,
vta_did: &str,
mediator_did: &str,
rest_url: Option<String>,
) -> Result<Session, Box<dyn std::error::Error>> {
debug!(temp_did = %session.client_did, "fetching ACL entry for temp DID over DIDComm");
let acl_entry = client.get_acl(&session.client_did).await.map_err(|e| {
format!(
"rotate (DIDComm): cannot read temp DID's ACL entry: {e} — \
has your admin run `vta import-did --did {} --role admin` yet?",
session.client_did
)
})?;
let role = acl_entry.role.clone();
let contexts = acl_entry.allowed_contexts.clone();
let label = acl_entry.label.clone();
let (new_did, new_private_key) = generate_did_key()?;
debug!(%new_did, %role, "minted rotation DID, creating ACL entry over DIDComm");
let mut create_req = crate::client::CreateAclRequest::new(&new_did, role).contexts(contexts);
if let Some(l) = label {
create_req = create_req.label(l);
}
client
.create_acl(create_req)
.await
.map_err(|e| format!("rotate (DIDComm): failed to create ACL entry for new DID: {e}"))?;
let probe = crate::client::VtaClient::connect_didcomm(
&new_did,
&new_private_key,
vta_did,
mediator_did,
rest_url,
)
.await
.map_err(|e| {
format!(
"rotate (DIDComm): new DID failed authcrypt probe (ACL entry present \
but DIDComm session refused): {e}"
)
})?;
match probe.delete_acl(&session.client_did).await {
Ok(_) => {
debug!(temp_did = %session.client_did, "temp DID removed from ACL over DIDComm");
}
Err(e) => {
tracing::warn!(
temp_did = %session.client_did,
error = %e,
"could not delete temp DID from ACL after rotation (DIDComm) — \
manual cleanup may be required"
);
}
}
probe.shutdown().await;
let Session { vta_did, .. } = session.clone();
Ok(Session {
client_did: new_did,
private_key: new_private_key,
vta_did,
access_token: None,
access_expires_at: None,
needs_rotation: false,
})
}
fn generate_did_key() -> Result<(String, String), Box<dyn std::error::Error>> {
let mut seed = [0u8; 32];
getrandom::fill(&mut seed)
.map_err(|e| format!("CSPRNG failed while minting rotated did:key: {e}"))?;
let signing = ed25519_dalek::SigningKey::from_bytes(&seed);
let pubkey = signing.verifying_key().to_bytes();
let did = format!(
"did:key:{}",
crate::did_key::ed25519_multibase_pubkey(&pubkey)
);
let private_key_multibase = multibase::encode(multibase::Base::Base58Btc, seed);
Ok((did, private_key_multibase))
}
async fn rotate_key(
base_url: &str,
session: Session,
temp_token: &str,
) -> Result<(Session, TokenResult), Box<dyn std::error::Error>> {
let http = reqwest::Client::new();
let acl_url = format!(
"{}/acl/{}",
base_url.trim_end_matches('/'),
&session.client_did
);
debug!(url = %acl_url, "fetching ACL entry for temp DID");
let acl_resp = http
.get(&acl_url)
.bearer_auth(temp_token)
.send()
.await
.map_err(|e| format!("GET {acl_url}: {e}"))?;
if !acl_resp.status().is_success() {
let status = acl_resp.status();
let body = acl_resp.text().await.unwrap_or_default();
return Err(format!(
"rotate: cannot read temp DID's ACL entry ({status}): {body} — has your admin run `vta import-did --did {} --role admin` yet?",
session.client_did
)
.into());
}
let acl_entry: crate::client::AclEntryResponse = acl_resp
.json()
.await
.map_err(|e| format!("parse ACL entry: {e}"))?;
let role = acl_entry.role.clone();
let contexts = acl_entry.allowed_contexts.clone();
let label = acl_entry.label.clone();
let (new_did, new_private_key) = generate_did_key()?;
debug!(%new_did, %role, "minted rotation DID, creating ACL entry");
let mut create_req = crate::client::CreateAclRequest::new(&new_did, role).contexts(contexts);
if let Some(l) = label {
create_req = create_req.label(l);
}
let acl_post = format!("{}/acl", base_url.trim_end_matches('/'));
let create_resp = http
.post(&acl_post)
.bearer_auth(temp_token)
.json(&create_req)
.send()
.await
.map_err(|e| format!("POST {acl_post}: {e}"))?;
if !create_resp.status().is_success() {
let status = create_resp.status();
let body = create_resp.text().await.unwrap_or_default();
return Err(
format!("rotate: failed to create ACL entry for new DID ({status}): {body}").into(),
);
}
let session_vta_did = session
.vta_did
.as_deref()
.expect("ensure_authenticated gates vta_did.is_some() before calling rotate_key");
let new_token_result = challenge_response(
base_url,
&new_did,
&new_private_key,
session_vta_did,
)
.await
.map_err(|e| {
format!(
"rotate: new DID failed challenge-response (ACL entry present but login failed): {e}"
)
})?;
let del_url = format!(
"{}/acl/{}",
base_url.trim_end_matches('/'),
&session.client_did
);
match http
.delete(&del_url)
.bearer_auth(&new_token_result.access_token)
.send()
.await
{
Ok(resp) if resp.status().is_success() => {
debug!(temp_did = %session.client_did, "temp DID removed from ACL");
}
Ok(resp) => {
tracing::warn!(
temp_did = %session.client_did,
status = %resp.status(),
"could not delete temp DID from ACL after rotation — manual cleanup may be required",
);
}
Err(e) => {
tracing::warn!(
temp_did = %session.client_did,
error = %e,
"could not delete temp DID from ACL after rotation — manual cleanup may be required",
);
}
}
let Session { vta_did, .. } = session;
let rotated = Session {
client_did: new_did,
private_key: new_private_key,
vta_did,
access_token: None,
access_expires_at: None,
needs_rotation: false,
};
Ok((rotated, new_token_result))
}
pub async fn challenge_response(
base_url: &str,
client_did: &str,
private_key_multibase: &str,
vta_did: &str,
) -> Result<TokenResult, Box<dyn std::error::Error>> {
debug!(
base_url,
client_did, vta_did, "starting challenge-response auth"
);
let http = reqwest::Client::new();
let challenge_url = format!("{base_url}/auth/challenge");
debug!(url = %challenge_url, did = client_did, "requesting challenge");
let challenge_resp = http
.post(&challenge_url)
.json(&ChallengeRequest {
did: client_did.to_string(),
})
.send()
.await
.map_err(|e| format!("could not connect to VTA at {challenge_url}: {e}"))?;
if !challenge_resp.status().is_success() {
let status = challenge_resp.status();
let body = challenge_resp.text().await.unwrap_or_default();
return Err(format!("challenge request failed ({status}): {body}").into());
}
let challenge_text = challenge_resp
.text()
.await
.map_err(|e| format!("failed to read challenge response from VTA: {e}"))?;
let challenge: ChallengeResponse = serde_json::from_str(&challenge_text).map_err(|e| {
format!("unexpected response from VTA at {challenge_url} (is this a VTA server?): {e}")
})?;
debug!(
session_id = %challenge.session_id,
challenge = %challenge.data.challenge,
"challenge received"
);
debug!("initializing DID resolver and ATM for message packing");
use affinidi_tdk::common::TDKSharedState;
use affinidi_tdk::common::config::TDKConfig;
use affinidi_tdk::messaging::ATM;
use affinidi_tdk::messaging::config::ATMConfig;
use std::sync::Arc;
let tdk = TDKSharedState::new(
TDKConfig::builder()
.build()
.map_err(|e| format!("TDK config build failed: {e}"))?,
)
.await
.map_err(|e| format!("TDK init failed: {e}"))?;
let seed = crate::did_key::decode_private_key_multibase(private_key_multibase)?;
let secrets = crate::did_key::secrets_from_did_key(client_did, &seed)?;
debug!(signing_id = %secrets.signing.id, ka_id = %secrets.key_agreement.id, "inserting DIDComm secrets");
tdk.secrets_resolver().insert(secrets.signing).await;
tdk.secrets_resolver().insert(secrets.key_agreement).await;
let atm = ATM::new(
ATMConfig::builder()
.build()
.map_err(|e| format!("ATM config build failed: {e}"))?,
Arc::new(tdk),
)
.await
.map_err(|e| format!("ATM init failed: {e}"))?;
debug!(
from = client_did,
to = vta_did,
"building DIDComm authenticate message"
);
let msg = Message::build(
uuid::Uuid::new_v4().to_string(),
"https://affinidi.com/atm/1.0/authenticate".to_string(),
serde_json::json!({
"challenge": challenge.data.challenge,
"session_id": challenge.session_id,
}),
)
.from(client_did.to_string())
.to(vta_did.to_string())
.finalize();
let (packed, _metadata) = atm
.pack_encrypted(&msg, vta_did, Some(client_did), None)
.await
.map_err(|e| format!("DIDComm pack failed: {e}"))?;
debug!(packed_len = packed.len(), "message packed");
let auth_url = format!("{base_url}/auth/");
debug!(url = %auth_url, "sending packed message");
let auth_resp = http
.post(&auth_url)
.header("content-type", "text/plain")
.body(packed)
.send()
.await
.map_err(|e| format!("could not connect to VTA at {auth_url}: {e}"))?;
let status = auth_resp.status();
debug!(status = %status, "auth response received");
if !status.is_success() {
let body = auth_resp.text().await.unwrap_or_default();
return Err(format!("authentication failed ({status}): {body}").into());
}
let auth_text = auth_resp
.text()
.await
.map_err(|e| format!("failed to read auth response from VTA: {e}"))?;
let auth_data: AuthenticateResponse = serde_json::from_str(&auth_text).map_err(|e| {
format!("unexpected response from VTA at {auth_url} (is this a VTA server?): {e}")
})?;
debug!(
expires_at = auth_data.data.access_expires_at,
"authentication successful"
);
Ok(TokenResult {
access_token: auth_data.data.access_token,
access_expires_at: auth_data.data.access_expires_at,
})
}
pub enum VtaEndpoint {
Rest { url: String },
DIDComm {
vta_did: String,
mediator_did: String,
rest_url: Option<String>,
},
}
pub async fn resolve_vta_endpoint(
vta_did: &str,
) -> Result<VtaEndpoint, Box<dyn std::error::Error>> {
debug!(vta_did, "resolving VTA DID for transport selection");
let did_resolver = DIDCacheClient::new(DIDCacheConfigBuilder::default().build())
.await
.map_err(|e| format!("DID resolver init failed: {e}"))?;
let resolved = match did_resolver.resolve(vta_did).await {
Ok(r) => r,
Err(e) => {
debug!(error = %e, "DID resolution failed, falling back to URL parsing");
let url = url_from_did(vta_did)
.ok_or_else(|| format!("Could not determine VTA URL from DID: {vta_did}"))?;
return Ok(VtaEndpoint::Rest { url });
}
};
let rest_url = resolved
.doc
.find_service("vta-rest")
.and_then(|svc| svc.service_endpoint.get_uri())
.map(|u| u.trim_matches('"').trim_end_matches('/').to_string());
let mediator_did = resolved
.doc
.service
.iter()
.filter(|svc| svc.type_.iter().any(|t| t == "DIDCommMessaging"))
.flat_map(|svc| svc.service_endpoint.get_uris())
.map(|u| u.trim_matches('"').to_string())
.find(|u| u.starts_with("did:"));
if let Some(mediator_did) = mediator_did {
debug!(mediator_did = %mediator_did, rest_url = ?rest_url, "DIDComm endpoint found");
Ok(VtaEndpoint::DIDComm {
vta_did: vta_did.to_string(),
mediator_did,
rest_url,
})
} else if let Some(url) = rest_url {
debug!(url = %url, "REST-only endpoint found");
Ok(VtaEndpoint::Rest { url })
} else {
let url = url_from_did(vta_did)
.ok_or_else(|| format!("Could not determine VTA URL from DID: {vta_did}"))?;
debug!(url = %url, "falling back to URL from DID string");
Ok(VtaEndpoint::Rest { url })
}
}
pub async fn resolve_vta_url(vta_did: &str) -> Result<String, Box<dyn std::error::Error>> {
debug!(vta_did, "resolving VTA DID to discover service URL");
let did_resolver = DIDCacheClient::new(DIDCacheConfigBuilder::default().build())
.await
.map_err(|e| format!("DID resolver init failed: {e}"))?;
match did_resolver.resolve(vta_did).await {
Ok(resolved) => {
if let Some(svc) = resolved.doc.find_service("vta-rest")
&& let Some(url) = svc.service_endpoint.get_uri()
{
let url = url.trim_matches('"').trim_end_matches('/').to_string();
debug!(url = %url, "found VTA URL from #vta-rest service endpoint");
return Ok(url);
}
debug!("no #vta-rest service found in DID document, falling back to DID parsing");
}
Err(e) => {
debug!(error = %e, "DID resolution failed, falling back to DID parsing");
}
}
url_from_did(vta_did)
.ok_or_else(|| format!("Could not determine VTA URL from DID: {vta_did}").into())
}
fn url_from_did(did: &str) -> Option<String> {
let domain = if let Some(rest) = did.strip_prefix("did:web:") {
rest.split(':').next()
} else if let Some(rest) = did.strip_prefix("did:webvh:") {
rest.split(':').nth(1)
} else {
None
}?;
let decoded = domain.replace("%3A", ":").replace("%3a", ":");
Some(format!("https://{decoded}"))
}
pub async fn send_trust_ping(
client_did: &str,
private_key_multibase: &str,
mediator_did: &str,
target_did: Option<&str>,
) -> Result<u128, Box<dyn std::error::Error>> {
use std::sync::Arc;
use std::time::Instant;
use affinidi_tdk::common::TDKSharedState;
use affinidi_tdk::common::config::TDKConfig;
use affinidi_tdk::messaging::ATM;
use affinidi_tdk::messaging::config::ATMConfig;
use affinidi_tdk::messaging::profiles::ATMProfile;
use affinidi_tdk::messaging::protocols::trust_ping::TrustPing;
let seed = crate::did_key::decode_private_key_multibase(private_key_multibase)?;
let secrets = crate::did_key::secrets_from_did_key(client_did, &seed)?;
let tdk = TDKSharedState::new(TDKConfig::builder().build()?).await?;
tdk.secrets_resolver().insert(secrets.signing).await;
tdk.secrets_resolver().insert(secrets.key_agreement).await;
let atm = ATM::new(ATMConfig::builder().build()?, Arc::new(tdk)).await?;
let profile = ATMProfile::new(
&atm,
None,
client_did.to_string(),
Some(mediator_did.to_string()),
)
.await?;
let profile = Arc::new(profile);
atm.profile_enable_websocket(&profile).await?;
let start = Instant::now();
TrustPing::default()
.send_ping(
&atm,
&profile,
target_did.unwrap_or(mediator_did),
true,
true,
true,
)
.await?;
let elapsed = start.elapsed().as_millis();
atm.graceful_shutdown().await;
Ok(elapsed)
}
pub async fn resolve_mediator_did(
vta_did: &str,
) -> Result<Option<String>, Box<dyn std::error::Error>> {
let did_resolver = DIDCacheClient::new(DIDCacheConfigBuilder::default().build())
.await
.map_err(|e| format!("DID resolver init failed: {e}"))?;
resolve_mediator_did_with_resolver(vta_did, &did_resolver).await
}
pub async fn resolve_mediator_did_with_resolver(
vta_did: &str,
resolver: &DIDCacheClient,
) -> Result<Option<String>, Box<dyn std::error::Error>> {
let resolved = resolver
.resolve(vta_did)
.await
.map_err(|e| format!("DID resolution failed: {e}"))?;
for svc in &resolved.doc.service {
if svc.type_.iter().any(|t| t == "DIDCommMessaging")
&& let Some(did) = svc
.service_endpoint
.get_uris()
.into_iter()
.map(|u| u.trim_matches('"').to_string())
.find(|u| u.starts_with("did:"))
{
return Ok(Some(did));
}
}
Ok(None)
}
pub struct TrustPingSession {
atm: affinidi_tdk::messaging::ATM,
profile: std::sync::Arc<affinidi_tdk::messaging::profiles::ATMProfile>,
mediator_did: String,
}
impl TrustPingSession {
pub async fn new(
client_did: &str,
private_key_multibase: &str,
mediator_did: &str,
) -> Result<Self, Box<dyn std::error::Error>> {
use affinidi_tdk::common::TDKSharedState;
use affinidi_tdk::common::config::TDKConfig;
use affinidi_tdk::messaging::ATM;
use affinidi_tdk::messaging::config::ATMConfig;
use affinidi_tdk::messaging::profiles::ATMProfile;
use std::sync::Arc;
let seed = crate::did_key::decode_private_key_multibase(private_key_multibase)?;
let secrets = crate::did_key::secrets_from_did_key(client_did, &seed)?;
let tdk = TDKSharedState::new(TDKConfig::builder().build()?).await?;
tdk.secrets_resolver().insert(secrets.signing).await;
tdk.secrets_resolver().insert(secrets.key_agreement).await;
let atm = ATM::new(ATMConfig::builder().build()?, Arc::new(tdk)).await?;
let profile = ATMProfile::new(
&atm,
None,
client_did.to_string(),
Some(mediator_did.to_string()),
)
.await?;
let profile = Arc::new(profile);
atm.profile_enable_websocket(&profile).await?;
Ok(Self {
atm,
profile,
mediator_did: mediator_did.to_string(),
})
}
pub async fn ping(&self, target_did: Option<&str>) -> Result<u128, Box<dyn std::error::Error>> {
use affinidi_tdk::messaging::protocols::trust_ping::TrustPing;
use std::time::Instant;
let target = target_did.unwrap_or(&self.mediator_did);
let start = Instant::now();
TrustPing::default()
.send_ping(&self.atm, &self.profile, target, true, true, true)
.await?;
Ok(start.elapsed().as_millis())
}
pub async fn shutdown(self) {
self.atm.graceful_shutdown().await;
}
}
fn now_epoch() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_session_round_trip() {
let session = Session {
client_did: "did:key:z6Mk1".into(),
private_key: "z_seed".into(),
vta_did: Some("did:key:z6MkVTA".into()),
access_token: Some("tok123".into()),
access_expires_at: Some(1700000000),
needs_rotation: false,
};
let json = serde_json::to_string(&session).unwrap();
let restored: Session = serde_json::from_str(&json).unwrap();
assert_eq!(restored.client_did, session.client_did);
assert_eq!(restored.private_key, session.private_key);
assert_eq!(restored.vta_did, session.vta_did);
assert_eq!(restored.access_token, session.access_token);
assert_eq!(restored.access_expires_at, session.access_expires_at);
}
#[test]
fn test_session_legacy_vta_url_field_silently_dropped() {
let json = r#"{
"client_did": "did:key:z6Mk1",
"private_key": "z_seed",
"vta_did": "did:key:z6MkVTA",
"vta_url": "https://stale.example.com",
"access_token": null,
"access_expires_at": null
}"#;
let session: Session = serde_json::from_str(json).unwrap();
assert_eq!(session.vta_did.as_deref(), Some("did:key:z6MkVTA"));
let reserialised = serde_json::to_string(&session).unwrap();
assert!(!reserialised.contains("vta_url"));
}
#[test]
fn test_session_vta_did_defaults_to_none_when_missing() {
let json = r#"{
"client_did": "did:key:z6MkPending",
"private_key": "z_seed",
"access_token": null,
"access_expires_at": null
}"#;
let session: Session = serde_json::from_str(json).unwrap();
assert!(session.vta_did.is_none());
}
#[test]
fn test_session_vta_did_round_trips_null() {
let session = Session {
client_did: "did:key:z6MkPending".into(),
private_key: "z_seed".into(),
vta_did: None,
access_token: None,
access_expires_at: None,
needs_rotation: false,
};
let json = serde_json::to_string(&session).unwrap();
let restored: Session = serde_json::from_str(&json).unwrap();
assert!(restored.vta_did.is_none());
}
#[test]
fn test_now_epoch_is_recent() {
let epoch = now_epoch();
assert!(epoch > 1704067200, "epoch {epoch} should be after 2024");
assert!(epoch < 4102444800, "epoch {epoch} should be before 2100");
}
#[test]
fn test_url_from_did_web() {
assert_eq!(
url_from_did("did:web:vta.example.com"),
Some("https://vta.example.com".to_string())
);
}
#[test]
fn test_url_from_did_web_with_port() {
assert_eq!(
url_from_did("did:web:localhost%3A8100"),
Some("https://localhost:8100".to_string())
);
}
#[test]
fn test_url_from_did_webvh() {
assert_eq!(
url_from_did("did:webvh:QmSCID123:vta.example.com"),
Some("https://vta.example.com".to_string())
);
}
#[test]
fn test_url_from_did_webvh_with_port() {
assert_eq!(
url_from_did("did:webvh:QmSCID123:localhost%3A8100"),
Some("https://localhost:8100".to_string())
);
}
#[test]
fn test_url_from_did_key_returns_none() {
assert_eq!(url_from_did("did:key:z6MkTest"), None);
}
fn test_store() -> SessionStore {
SessionStore::with_backend(Box::new(testing::InMemorySessionBackend::new()))
}
#[test]
fn test_in_memory_backend() {
let store = test_store();
assert!(!store.has_session("test"));
store
.store_direct("test", "did:key:z6Mk1", "zSeed", "did:key:zVTA")
.unwrap();
assert!(store.has_session("test"));
let info = store.loaded_session("test").unwrap();
assert_eq!(info.client_did, "did:key:z6Mk1");
assert_eq!(info.vta_did.as_deref(), Some("did:key:zVTA"));
store.logout("test");
assert!(!store.has_session("test"));
}
#[test]
fn store_pending_vta_binding_round_trips() {
let store = test_store();
store
.store_pending_vta_binding("slug", "did:key:z6MkPending", "zSeed123")
.unwrap();
assert!(store.has_pending_vta_binding("slug"));
let info = store.loaded_session("slug").unwrap();
assert_eq!(info.client_did, "did:key:z6MkPending");
assert!(info.vta_did.is_none());
}
#[test]
fn store_pending_vta_binding_rejects_non_did_key() {
let store = test_store();
let err = store
.store_pending_vta_binding("slug", "did:web:something", "zSeed")
.unwrap_err();
assert!(err.to_string().contains("did:key"));
}
#[test]
fn store_pending_vta_binding_rejects_empty_inputs() {
let store = test_store();
assert!(
store
.store_pending_vta_binding(" ", "did:key:z6Mk", "zSeed")
.is_err()
);
assert!(
store
.store_pending_vta_binding("slug", "did:key:z6Mk", "")
.is_err()
);
}
#[test]
fn bind_vta_did_promotes_pending_to_rotation() {
let store = test_store();
store
.store_pending_vta_binding("slug", "did:key:z6MkPending", "zSeed")
.unwrap();
store
.bind_vta_did("slug", "did:webvh:abc:vta.example.com:primary")
.unwrap();
assert!(!store.has_pending_vta_binding("slug"));
let info = store.loaded_session("slug").unwrap();
assert_eq!(info.client_did, "did:key:z6MkPending");
assert_eq!(
info.vta_did.as_deref(),
Some("did:webvh:abc:vta.example.com:primary")
);
}
#[test]
fn bind_vta_did_accepts_did_key_vta() {
let store = test_store();
store
.store_pending_vta_binding("slug", "did:key:z6MkPending", "zSeed")
.unwrap();
store.bind_vta_did("slug", "did:key:z6MkVTA").unwrap();
}
#[test]
fn bind_vta_did_rejects_rebind() {
let store = test_store();
store
.store_direct("slug", "did:key:z6Mk", "zSeed", "did:web:vta.example.com")
.unwrap();
let err = store
.bind_vta_did("slug", "did:web:other.example.com")
.unwrap_err();
assert!(err.to_string().contains("already has a VTA DID bound"));
}
#[test]
fn bind_vta_did_rejects_missing_session() {
let store = test_store();
let err = store
.bind_vta_did("no-such-slug", "did:web:vta.example.com")
.unwrap_err();
assert!(err.to_string().contains("no session found"));
}
#[test]
fn bind_vta_did_rejects_malformed_input() {
let store = test_store();
store
.store_pending_vta_binding("slug", "did:key:z6Mk", "zSeed")
.unwrap();
assert!(store.bind_vta_did("slug", " ").is_err());
assert!(store.bind_vta_did("slug", "not-a-did").is_err());
}
#[test]
fn has_pending_vta_binding_false_for_direct_session() {
let store = test_store();
store
.store_direct("slug", "did:key:z6Mk", "zSeed", "did:web:vta.example.com")
.unwrap();
assert!(!store.has_pending_vta_binding("slug"));
}
#[test]
fn has_pending_vta_binding_false_for_missing_entry() {
let store = test_store();
assert!(!store.has_pending_vta_binding("nope"));
}
#[test]
fn require_vta_did_errors_on_pending() {
let pending = Session {
client_did: "did:key:z6MkPending".into(),
private_key: "zSeed".into(),
vta_did: None,
access_token: None,
access_expires_at: None,
needs_rotation: false,
};
let err = require_vta_did(&pending).unwrap_err();
assert!(err.to_string().contains("pnm setup continue"));
}
}