use crate::error::VtaError;
use crate::keys::{KeyRecord, KeyStatus, KeyType};
use crate::protocols::key_management::sign::SignAlgorithm;
use chrono::{DateTime, Utc};
use reqwest::{Client, RequestBuilder};
use serde::{Deserialize, Serialize};
struct AuthCredential {
did: String,
private_key_multibase: String,
vta_did: String,
}
struct RestAuth {
token: Option<String>,
expires_at: Option<u64>,
refresh_token: Option<String>,
refresh_expires_at: Option<u64>,
credential: Option<AuthCredential>,
}
#[derive(Clone)]
enum Transport {
Rest {
client: Client,
base_url: String,
auth: std::sync::Arc<tokio::sync::Mutex<RestAuth>>,
},
#[cfg(feature = "session")]
DIDComm {
session: crate::didcomm_session::DIDCommSession,
rest_client: Option<Client>,
rest_url: Option<String>,
},
}
#[derive(Clone)]
pub struct VtaClient {
transport: Transport,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct HealthResponse {
pub status: String,
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub mediator_url: Option<String>,
#[serde(default)]
pub mediator_did: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct ConfigResponse {
#[serde(rename = "vta_did")]
pub community_vta_did: Option<String>,
#[serde(rename = "vta_name")]
pub community_vta_name: Option<String>,
pub public_url: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct UpdateConfigRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub vta_did: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub vta_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub public_url: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct CreateKeyRequest {
pub key_type: KeyType,
#[serde(skip_serializing_if = "Option::is_none")]
pub derivation_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub key_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mnemonic: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub context_id: Option<String>,
}
impl CreateKeyRequest {
pub fn new(key_type: KeyType) -> Self {
Self {
key_type,
derivation_path: None,
key_id: None,
mnemonic: None,
label: None,
context_id: None,
}
}
pub fn derivation_path(mut self, path: impl Into<String>) -> Self {
self.derivation_path = Some(path.into());
self
}
pub fn key_id(mut self, id: impl Into<String>) -> Self {
self.key_id = Some(id.into());
self
}
pub fn mnemonic(mut self, m: impl Into<String>) -> Self {
self.mnemonic = Some(m.into());
self
}
pub fn label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
pub fn context(mut self, ctx: impl Into<String>) -> Self {
self.context_id = Some(ctx.into());
self
}
}
#[derive(Debug, Serialize)]
pub struct ImportKeyRequest {
pub key_type: KeyType,
#[serde(skip_serializing_if = "Option::is_none")]
pub private_key_jwe: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub private_key_multibase: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub context_id: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct ImportKeyResponse {
pub key_id: String,
pub key_type: KeyType,
pub public_key: String,
pub status: KeyStatus,
pub label: Option<String>,
pub origin: crate::keys::KeyOrigin,
pub created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Deserialize)]
pub struct WrappingKeyResponse {
pub kid: String,
pub kty: String,
pub crv: String,
pub x: String,
}
#[derive(Debug, Serialize)]
pub struct CreateContextRequest {
pub id: String,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
impl CreateContextRequest {
pub fn new(id: impl Into<String>, name: impl Into<String>) -> Self {
Self {
id: id.into(),
name: name.into(),
description: None,
}
}
pub fn description(mut self, desc: impl Into<String>) -> Self {
self.description = Some(desc.into());
self
}
}
#[derive(Debug, Serialize)]
pub struct UpdateContextRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub did: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct UpdateContextDidRequest {
pub did: String,
}
#[derive(Debug, Deserialize)]
pub struct ContextResponse {
pub id: String,
pub name: String,
pub did: Option<String>,
pub description: Option<String>,
pub base_path: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
pub struct ContextListResponse {
pub contexts: Vec<ContextResponse>,
}
#[derive(Debug, Deserialize)]
pub struct CreateKeyResponse {
pub key_id: String,
pub key_type: KeyType,
pub derivation_path: String,
pub public_key: String,
pub status: KeyStatus,
pub label: Option<String>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
pub struct InvalidateKeyResponse {
pub key_id: String,
pub status: KeyStatus,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Serialize)]
pub struct RenameKeyRequest {
pub key_id: String,
}
#[derive(Debug, Deserialize)]
pub struct RenameKeyResponse {
pub key_id: String,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
pub struct GetKeySecretResponse {
pub key_id: String,
pub key_type: KeyType,
pub public_key_multibase: String,
pub private_key_multibase: String,
}
#[derive(Debug, Deserialize)]
pub struct SignResponse {
pub key_id: String,
pub signature: String,
pub algorithm: SignAlgorithm,
}
#[derive(Debug, Deserialize)]
pub struct ListKeysResponse {
pub keys: Vec<KeyRecord>,
pub total: u64,
}
#[derive(Debug, Deserialize)]
pub struct ErrorResponse {
pub error: String,
}
#[derive(Debug, Deserialize)]
pub struct SeedInfoResponse {
pub id: u32,
pub status: String,
pub created_at: DateTime<Utc>,
pub retired_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Deserialize)]
pub struct ListSeedsResponse {
pub seeds: Vec<SeedInfoResponse>,
pub active_seed_id: u32,
}
#[derive(Debug, Serialize)]
pub struct RotateSeedRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub mnemonic: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct RotateSeedResponse {
pub previous_seed_id: u32,
pub new_seed_id: u32,
}
#[derive(Debug, Deserialize)]
pub struct AclEntryResponse {
pub did: String,
pub role: String,
pub label: Option<String>,
pub allowed_contexts: Vec<String>,
pub created_at: u64,
pub created_by: String,
}
#[derive(Debug, Deserialize)]
pub struct AclListResponse {
pub entries: Vec<AclEntryResponse>,
}
#[derive(Debug, Serialize)]
pub struct CreateAclRequest {
pub did: String,
pub role: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
pub allowed_contexts: Vec<String>,
}
impl CreateAclRequest {
pub fn new(did: impl Into<String>, role: impl Into<String>) -> Self {
Self {
did: did.into(),
role: role.into(),
label: None,
allowed_contexts: Vec::new(),
}
}
pub fn label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
pub fn contexts(mut self, contexts: Vec<String>) -> Self {
self.allowed_contexts = contexts;
self
}
}
#[derive(Debug, Serialize)]
pub struct UpdateAclRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub allowed_contexts: Option<Vec<String>>,
}
#[derive(Debug, Serialize)]
pub struct AddWebvhServerRequest {
pub id: String,
pub did: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct UpdateWebvhServerRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct CreateDidWebvhRequest {
pub context_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub server_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
pub portable: bool,
pub add_mediator_service: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub additional_services: Option<Vec<serde_json::Value>>,
pub pre_rotation_count: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub did_document: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub did_log: Option<String>,
pub set_primary: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub signing_key_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ka_key_id: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct GetDidLogResponse {
pub did: String,
pub log: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct GenerateCredentialsRequest {
pub role: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
pub allowed_contexts: Vec<String>,
}
impl GenerateCredentialsRequest {
pub fn new(role: impl Into<String>) -> Self {
Self {
role: role.into(),
label: None,
allowed_contexts: Vec::new(),
}
}
pub fn label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
pub fn contexts(mut self, contexts: Vec<String>) -> Self {
self.allowed_contexts = contexts;
self
}
}
#[derive(Debug, Deserialize)]
pub struct GenerateCredentialsResponse {
pub did: String,
pub credential: String,
pub role: String,
}
fn encode_path_segment(s: &str) -> String {
s.replace('%', "%25")
.replace('#', "%23")
.replace('?', "%3F")
.replace('/', "%2F")
}
impl VtaClient {
fn with_auth_token(req: RequestBuilder, token: &Option<String>) -> RequestBuilder {
match token {
Some(token) => req.bearer_auth(token),
None => req,
}
}
async fn handle_response<T: serde::de::DeserializeOwned>(
resp: reqwest::Response,
) -> Result<T, VtaError> {
if resp.status().is_success() {
Ok(resp.json::<T>().await?)
} else {
let status = resp.status();
let body = resp
.json::<ErrorResponse>()
.await
.map(|e| e.error)
.unwrap_or_else(|_| "unknown error".to_string());
Err(VtaError::from_http(status, body))
}
}
async fn handle_delete_response(resp: reqwest::Response) -> Result<(), VtaError> {
if resp.status().is_success() {
Ok(())
} else {
let status = resp.status();
let body = resp
.json::<ErrorResponse>()
.await
.map(|e| e.error)
.unwrap_or_else(|_| "unknown error".to_string());
Err(VtaError::from_http(status, body))
}
}
}
#[cfg(feature = "client")]
use crate::protocols::{
acl_management, context_management, credential_management, did_management, key_management,
seed_management, vta_management,
};
impl VtaClient {
pub fn new(base_url: &str) -> Self {
Self {
transport: Transport::Rest {
client: Client::new(),
base_url: base_url.trim_end_matches('/').to_string(),
auth: std::sync::Arc::new(tokio::sync::Mutex::new(RestAuth {
token: None,
expires_at: None,
refresh_token: None,
refresh_expires_at: None,
credential: None,
})),
},
}
}
pub async fn from_credential(
credential_b64: &str,
url_override: Option<&str>,
) -> Result<Self, VtaError> {
let (result, cred, http) =
crate::auth_light::authenticate_with_credential(credential_b64, url_override).await?;
let base_url = url_override
.or(cred.vta_url.as_deref())
.ok_or("no VTA URL")?
.trim_end_matches('/')
.to_string();
Ok(Self {
transport: Transport::Rest {
client: http,
base_url,
auth: std::sync::Arc::new(tokio::sync::Mutex::new(RestAuth {
token: Some(result.access_token),
expires_at: Some(result.access_expires_at),
refresh_token: result.refresh_token,
refresh_expires_at: result.refresh_expires_at,
credential: Some(AuthCredential {
did: cred.did,
private_key_multibase: cred.private_key_multibase,
vta_did: cred.vta_did,
}),
})),
},
})
}
pub async fn token_expires_at(&self) -> Option<u64> {
if let Transport::Rest { auth, .. } = &self.transport {
auth.lock().await.expires_at
} else {
None
}
}
#[cfg(feature = "session")]
pub async fn connect_didcomm(
client_did: &str,
private_key_multibase: &str,
vta_did: &str,
mediator_did: &str,
rest_url: Option<String>,
) -> Result<Self, VtaError> {
let session = crate::didcomm_session::DIDCommSession::connect(
client_did,
private_key_multibase,
vta_did,
mediator_did,
)
.await?;
let rest_client = rest_url.as_ref().map(|_| Client::new());
Ok(Self {
transport: Transport::DIDComm {
session,
rest_client,
rest_url: rest_url.map(|u| u.trim_end_matches('/').to_string()),
},
})
}
pub fn set_token(&self, token: String) {
if let Transport::Rest { auth, .. } = &self.transport {
if let Ok(mut guard) = auth.try_lock() {
guard.token = Some(token);
}
}
}
pub async fn set_token_async(&self, token: String) {
if let Transport::Rest { auth, .. } = &self.transport {
auth.lock().await.token = Some(token);
}
}
pub fn base_url(&self) -> &str {
match &self.transport {
Transport::Rest { base_url, .. } => base_url,
#[cfg(feature = "session")]
Transport::DIDComm { session, .. } => &session.vta_did,
}
}
pub async fn shutdown(&self) {
#[cfg(feature = "session")]
if let Transport::DIDComm { session, .. } = &self.transport {
session.shutdown().await;
}
}
async fn ensure_token_valid(
client: &Client,
base_url: &str,
auth: &tokio::sync::Mutex<RestAuth>,
) -> Result<(), VtaError> {
let mut guard = auth.lock().await;
if let Some(expires_at) = guard.expires_at {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
if now + 30 < expires_at {
return Ok(()); }
} else if guard.token.is_some() {
return Ok(());
}
let Some(ref cred) = guard.credential else {
return Ok(());
};
if let Some(ref refresh_tok) = guard.refresh_token
&& let Some(refresh_exp) = guard.refresh_expires_at
{
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
if now < refresh_exp
&& let Ok(result) = crate::auth_light::refresh_token_light(
client,
base_url,
&cred.did,
&cred.vta_did,
refresh_tok,
)
.await
{
guard.token = Some(result.access_token);
guard.expires_at = Some(result.access_expires_at);
if let Some(new_refresh) = result.refresh_token {
guard.refresh_token = Some(new_refresh);
}
guard.refresh_expires_at = result.refresh_expires_at;
return Ok(());
}
}
let did = cred.did.clone();
let pk = cred.private_key_multibase.clone();
let vta = cred.vta_did.clone();
drop(guard);
let result =
crate::auth_light::challenge_response_light(client, base_url, &did, &pk, &vta).await?;
let mut guard = auth.lock().await;
guard.token = Some(result.access_token);
guard.expires_at = Some(result.access_expires_at);
guard.refresh_token = result.refresh_token;
guard.refresh_expires_at = result.refresh_expires_at;
Ok(())
}
#[allow(unused_variables)]
async fn rpc<T: serde::de::DeserializeOwned>(
&self,
msg_type: &str,
body: serde_json::Value,
result_type: &str,
timeout: u64,
build_rest: impl FnOnce(&Client, &str) -> RequestBuilder,
) -> Result<T, VtaError> {
match &self.transport {
Transport::Rest {
client,
base_url,
auth,
} => {
Self::ensure_token_valid(client, base_url, auth).await?;
let token = auth.lock().await.token.clone();
let req = build_rest(client, base_url);
let resp = Self::with_auth_token(req, &token).send().await?;
Self::handle_response(resp).await
}
#[cfg(feature = "session")]
Transport::DIDComm { session, .. } => session
.send_and_wait(msg_type, body, result_type, timeout)
.await
.map_err(|e| VtaError::Protocol(e.to_string())),
}
}
#[allow(unused_variables)]
async fn rpc_void(
&self,
msg_type: &str,
body: serde_json::Value,
result_type: &str,
timeout: u64,
build_rest: impl FnOnce(&Client, &str) -> RequestBuilder,
) -> Result<(), VtaError> {
match &self.transport {
Transport::Rest {
client,
base_url,
auth,
} => {
Self::ensure_token_valid(client, base_url, auth).await?;
let token = auth.lock().await.token.clone();
let req = build_rest(client, base_url);
let resp = Self::with_auth_token(req, &token).send().await?;
Self::handle_delete_response(resp).await
}
#[cfg(feature = "session")]
Transport::DIDComm { session, .. } => {
let _: serde_json::Value = session
.send_and_wait(msg_type, body, result_type, timeout)
.await
.map_err(|e| VtaError::Protocol(e.to_string()))?;
Ok(())
}
}
}
pub async fn health(&self) -> Result<HealthResponse, VtaError> {
match &self.transport {
Transport::Rest {
client, base_url, ..
} => {
let resp = client.get(format!("{base_url}/health")).send().await?;
Self::handle_response(resp).await
}
#[cfg(feature = "session")]
Transport::DIDComm {
rest_client,
rest_url,
..
} => match (rest_client, rest_url) {
(Some(client), Some(url)) => {
let resp = client.get(format!("{url}/health")).send().await?;
Self::handle_response(resp).await
}
_ => Err("health check not available via DIDComm (no REST URL)".into()),
},
}
}
}
#[cfg(feature = "client")]
impl VtaClient {
pub async fn capabilities(
&self,
) -> Result<crate::protocols::discovery::CapabilitiesResponse, VtaError> {
use crate::protocols::discovery;
self.rpc(
discovery::DISCOVER_CAPABILITIES,
serde_json::json!({}),
discovery::DISCOVER_CAPABILITIES_RESULT,
30,
|c, url| c.get(format!("{url}/capabilities")),
)
.await
}
pub async fn restart(&self) -> Result<vta_management::restart::RestartResult, VtaError> {
self.rpc(
vta_management::RESTART,
serde_json::json!({}),
vta_management::RESTART_RESULT,
30,
|c, url| {
c.post(format!("{url}/vta/restart"))
.json(&serde_json::json!({}))
},
)
.await
}
pub async fn backup_export(
&self,
password: &str,
include_audit: bool,
) -> Result<crate::protocols::backup_management::types::BackupEnvelope, VtaError> {
self.rpc(
crate::protocols::backup_management::EXPORT_BACKUP,
serde_json::json!({ "password": password, "include_audit": include_audit }),
crate::protocols::backup_management::EXPORT_BACKUP_RESULT,
120, |c, url| {
c.post(format!("{url}/backup/export")).json(
&serde_json::json!({ "password": password, "include_audit": include_audit }),
)
},
)
.await
}
pub async fn backup_import(
&self,
backup: &crate::protocols::backup_management::types::BackupEnvelope,
password: &str,
confirm: bool,
) -> Result<crate::protocols::backup_management::types::ImportResult, VtaError> {
self.rpc(
crate::protocols::backup_management::IMPORT_BACKUP,
serde_json::json!({ "backup": backup, "password": password, "confirm": confirm }),
crate::protocols::backup_management::IMPORT_BACKUP_RESULT,
120,
|c, url| {
c.post(format!("{url}/backup/import"))
.json(&serde_json::json!({ "backup": backup, "password": password, "confirm": confirm }))
},
)
.await
}
pub async fn get_config(&self) -> Result<ConfigResponse, VtaError> {
self.rpc(
vta_management::GET_CONFIG,
serde_json::json!({}),
vta_management::GET_CONFIG_RESULT,
30,
|c, url| c.get(format!("{url}/config")),
)
.await
}
pub async fn update_config(
&self,
req: UpdateConfigRequest,
) -> Result<ConfigResponse, VtaError> {
self.rpc(
vta_management::UPDATE_CONFIG,
serde_json::to_value(&req)?,
vta_management::UPDATE_CONFIG_RESULT,
30,
|c, url| c.patch(format!("{url}/config")).json(&req),
)
.await
}
pub async fn create_key(&self, req: CreateKeyRequest) -> Result<CreateKeyResponse, VtaError> {
self.rpc(
key_management::CREATE_KEY,
serde_json::json!({
"key_type": serde_json::to_value(&req.key_type)?,
"derivation_path": req.derivation_path.as_deref().unwrap_or_default(),
"mnemonic": req.mnemonic.as_deref(),
"label": req.label.as_deref(),
"context_id": req.context_id.as_deref(),
}),
key_management::CREATE_KEY_RESULT,
30,
|c, url| c.post(format!("{url}/keys")).json(&req),
)
.await
}
pub async fn list_keys(
&self,
offset: u64,
limit: u64,
status: Option<&str>,
context_id: Option<&str>,
) -> Result<ListKeysResponse, VtaError> {
self.rpc(
key_management::LIST_KEYS,
serde_json::json!({
"offset": offset,
"limit": limit,
"status": status,
"context_id": context_id,
}),
key_management::LIST_KEYS_RESULT,
30,
|c, url| {
let mut u = format!("{url}/keys?offset={offset}&limit={limit}");
if let Some(s) = status {
u.push_str(&format!("&status={s}"));
}
if let Some(ctx) = context_id {
u.push_str(&format!("&context_id={ctx}"));
}
c.get(u)
},
)
.await
}
pub async fn get_key(&self, key_id: &str) -> Result<KeyRecord, VtaError> {
self.rpc(
key_management::GET_KEY,
serde_json::json!({ "key_id": key_id }),
key_management::GET_KEY_RESULT,
30,
|c, url| c.get(format!("{url}/keys/{}", encode_path_segment(key_id))),
)
.await
}
pub async fn get_key_secret(&self, key_id: &str) -> Result<GetKeySecretResponse, VtaError> {
self.rpc(
key_management::GET_KEY_SECRET,
serde_json::json!({ "key_id": key_id }),
key_management::GET_KEY_SECRET_RESULT,
30,
|c, url| c.get(format!("{url}/keys/{}/secret", encode_path_segment(key_id))),
)
.await
}
pub async fn sign(
&self,
key_id: &str,
payload: &[u8],
algorithm: SignAlgorithm,
) -> Result<SignResponse, VtaError> {
use base64::Engine;
let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload);
self.rpc(
key_management::SIGN_REQUEST,
serde_json::json!({
"key_id": key_id,
"payload": payload_b64,
"algorithm": algorithm,
}),
key_management::SIGN_RESULT,
30,
|c, url| {
c.post(format!("{url}/keys/{}/sign", encode_path_segment(key_id)))
.json(&serde_json::json!({
"payload": payload_b64,
"algorithm": algorithm,
}))
},
)
.await
}
pub async fn invalidate_key(&self, key_id: &str) -> Result<InvalidateKeyResponse, VtaError> {
self.rpc(
key_management::REVOKE_KEY,
serde_json::json!({ "key_id": key_id }),
key_management::REVOKE_KEY_RESULT,
30,
|c, url| c.delete(format!("{url}/keys/{}", encode_path_segment(key_id))),
)
.await
}
pub async fn rename_key(
&self,
key_id: &str,
new_key_id: &str,
) -> Result<RenameKeyResponse, VtaError> {
self.rpc(
key_management::RENAME_KEY,
serde_json::json!({ "key_id": key_id, "new_key_id": new_key_id }),
key_management::RENAME_KEY_RESULT,
30,
|c, url| {
c.patch(format!("{url}/keys/{}", encode_path_segment(key_id)))
.json(&RenameKeyRequest {
key_id: new_key_id.to_string(),
})
},
)
.await
}
pub async fn get_wrapping_key(&self) -> Result<WrappingKeyResponse, VtaError> {
match &self.transport {
Transport::Rest {
client,
base_url,
auth,
} => {
Self::ensure_token_valid(client, base_url, auth).await?;
let token = auth.lock().await.token.clone();
let req = client.get(format!("{base_url}/keys/import/wrapping-key"));
let resp = Self::with_auth_token(req, &token).send().await?;
Self::handle_response(resp).await
}
#[cfg(feature = "session")]
Transport::DIDComm { .. } => Err(VtaError::Other(
"wrapping key not needed for DIDComm transport".into(),
)),
}
}
pub async fn import_key(&self, req: ImportKeyRequest) -> Result<ImportKeyResponse, VtaError> {
self.rpc(
key_management::IMPORT_KEY,
serde_json::to_value(&req)?,
key_management::IMPORT_KEY_RESULT,
30,
|c, url| c.post(format!("{url}/keys/import")).json(&req),
)
.await
}
pub async fn list_seeds(&self) -> Result<ListSeedsResponse, VtaError> {
self.rpc(
seed_management::LIST_SEEDS,
serde_json::json!({}),
seed_management::LIST_SEEDS_RESULT,
30,
|c, url| c.get(format!("{url}/keys/seeds")),
)
.await
}
pub async fn rotate_seed(
&self,
mnemonic: Option<String>,
) -> Result<RotateSeedResponse, VtaError> {
let body = RotateSeedRequest {
mnemonic: mnemonic.clone(),
};
self.rpc(
seed_management::ROTATE_SEED,
serde_json::json!({ "mnemonic": mnemonic }),
seed_management::ROTATE_SEED_RESULT,
30,
|c, url| c.post(format!("{url}/keys/seeds/rotate")).json(&body),
)
.await
}
pub async fn list_acl(&self, context: Option<&str>) -> Result<AclListResponse, VtaError> {
self.rpc(
acl_management::LIST_ACL,
serde_json::json!({ "context": context }),
acl_management::LIST_ACL_RESULT,
30,
|c, url| {
let mut u = format!("{url}/acl");
if let Some(ctx) = context {
u.push_str(&format!("?context={ctx}"));
}
c.get(u)
},
)
.await
}
pub async fn get_acl(&self, did: &str) -> Result<AclEntryResponse, VtaError> {
self.rpc(
acl_management::GET_ACL,
serde_json::json!({ "did": did }),
acl_management::GET_ACL_RESULT,
30,
|c, url| c.get(format!("{url}/acl/{}", encode_path_segment(did))),
)
.await
}
pub async fn create_acl(&self, req: CreateAclRequest) -> Result<AclEntryResponse, VtaError> {
self.rpc(
acl_management::CREATE_ACL,
serde_json::to_value(&req)?,
acl_management::CREATE_ACL_RESULT,
30,
|c, url| c.post(format!("{url}/acl")).json(&req),
)
.await
}
pub async fn update_acl(
&self,
did: &str,
req: UpdateAclRequest,
) -> Result<AclEntryResponse, VtaError> {
self.rpc(
acl_management::UPDATE_ACL,
serde_json::json!({
"did": did,
"role": &req.role,
"label": &req.label,
"allowed_contexts": &req.allowed_contexts,
}),
acl_management::UPDATE_ACL_RESULT,
30,
|c, url| {
c.patch(format!("{url}/acl/{}", encode_path_segment(did)))
.json(&req)
},
)
.await
}
pub async fn delete_acl(&self, did: &str) -> Result<(), VtaError> {
self.rpc_void(
acl_management::DELETE_ACL,
serde_json::json!({ "did": did }),
acl_management::DELETE_ACL_RESULT,
30,
|c, url| c.delete(format!("{url}/acl/{}", encode_path_segment(did))),
)
.await
}
pub async fn generate_credentials(
&self,
req: GenerateCredentialsRequest,
) -> Result<GenerateCredentialsResponse, VtaError> {
self.rpc(
credential_management::GENERATE_CREDENTIALS,
serde_json::to_value(&req)?,
credential_management::GENERATE_CREDENTIALS_RESULT,
30,
|c, url| c.post(format!("{url}/auth/credentials")).json(&req),
)
.await
}
pub async fn list_contexts(&self) -> Result<ContextListResponse, VtaError> {
self.rpc(
context_management::LIST_CONTEXTS,
serde_json::json!({}),
context_management::LIST_CONTEXTS_RESULT,
30,
|c, url| c.get(format!("{url}/contexts")),
)
.await
}
pub async fn get_context(&self, id: &str) -> Result<ContextResponse, VtaError> {
self.rpc(
context_management::GET_CONTEXT,
serde_json::json!({ "id": id }),
context_management::GET_CONTEXT_RESULT,
30,
|c, url| c.get(format!("{url}/contexts/{}", encode_path_segment(id))),
)
.await
}
pub async fn create_context(
&self,
req: CreateContextRequest,
) -> Result<ContextResponse, VtaError> {
self.rpc(
context_management::CREATE_CONTEXT,
serde_json::to_value(&req)?,
context_management::CREATE_CONTEXT_RESULT,
30,
|c, url| c.post(format!("{url}/contexts")).json(&req),
)
.await
}
pub async fn update_context(
&self,
id: &str,
req: UpdateContextRequest,
) -> Result<ContextResponse, VtaError> {
self.rpc(
context_management::UPDATE_CONTEXT,
serde_json::json!({
"id": id,
"name": &req.name,
"did": &req.did,
"description": &req.description,
}),
context_management::UPDATE_CONTEXT_RESULT,
30,
|c, url| {
c.patch(format!("{url}/contexts/{}", encode_path_segment(id)))
.json(&req)
},
)
.await
}
pub async fn update_context_did(
&self,
id: &str,
did: impl Into<String>,
) -> Result<ContextResponse, VtaError> {
let did = did.into();
self.rpc(
context_management::UPDATE_CONTEXT_DID,
serde_json::json!({ "id": id, "did": &did }),
context_management::UPDATE_CONTEXT_DID_RESULT,
30,
|c, url| {
c.put(format!("{url}/contexts/{}/did", encode_path_segment(id)))
.json(&UpdateContextDidRequest { did: did.clone() })
},
)
.await
}
pub async fn preview_delete_context(
&self,
id: &str,
) -> Result<context_management::delete::DeleteContextPreviewResultBody, VtaError> {
self.rpc(
context_management::PREVIEW_DELETE_CONTEXT,
serde_json::json!({ "id": id }),
context_management::PREVIEW_DELETE_CONTEXT_RESULT,
30,
|c, url| {
c.get(format!(
"{url}/contexts/{}/delete-preview",
encode_path_segment(id)
))
},
)
.await
}
pub async fn delete_context(&self, id: &str, force: bool) -> Result<(), VtaError> {
self.rpc_void(
context_management::DELETE_CONTEXT,
serde_json::json!({ "id": id, "force": force }),
context_management::DELETE_CONTEXT_RESULT,
30,
|c, url| {
let mut url = format!("{url}/contexts/{}", encode_path_segment(id));
if force {
url.push_str("?force=true");
}
c.delete(url)
},
)
.await
}
pub async fn add_webvh_server(
&self,
req: AddWebvhServerRequest,
) -> Result<crate::webvh::WebvhServerRecord, VtaError> {
self.rpc(
did_management::ADD_WEBVH_SERVER,
serde_json::to_value(&req)?,
did_management::ADD_WEBVH_SERVER_RESULT,
30,
|c, url| c.post(format!("{url}/webvh/servers")).json(&req),
)
.await
}
pub async fn list_webvh_servers(
&self,
) -> Result<crate::protocols::did_management::servers::ListWebvhServersResultBody, VtaError>
{
self.rpc(
did_management::LIST_WEBVH_SERVERS,
serde_json::json!({}),
did_management::LIST_WEBVH_SERVERS_RESULT,
30,
|c, url| c.get(format!("{url}/webvh/servers")),
)
.await
}
pub async fn update_webvh_server(
&self,
id: &str,
req: UpdateWebvhServerRequest,
) -> Result<crate::webvh::WebvhServerRecord, VtaError> {
self.rpc(
did_management::UPDATE_WEBVH_SERVER,
serde_json::json!({ "id": id, "label": &req.label }),
did_management::UPDATE_WEBVH_SERVER_RESULT,
30,
|c, url| {
c.patch(format!("{url}/webvh/servers/{}", encode_path_segment(id)))
.json(&req)
},
)
.await
}
pub async fn remove_webvh_server(&self, id: &str) -> Result<(), VtaError> {
self.rpc_void(
did_management::REMOVE_WEBVH_SERVER,
serde_json::json!({ "id": id }),
did_management::REMOVE_WEBVH_SERVER_RESULT,
30,
|c, url| c.delete(format!("{url}/webvh/servers/{}", encode_path_segment(id))),
)
.await
}
pub async fn create_did_webvh(
&self,
req: CreateDidWebvhRequest,
) -> Result<crate::protocols::did_management::create::CreateDidWebvhResultBody, VtaError> {
self.rpc(
did_management::CREATE_DID_WEBVH,
serde_json::to_value(&req)?,
did_management::CREATE_DID_WEBVH_RESULT,
60,
|c, url| c.post(format!("{url}/webvh/dids")).json(&req),
)
.await
}
pub async fn list_dids_webvh(
&self,
context_id: Option<&str>,
server_id: Option<&str>,
) -> Result<crate::protocols::did_management::list::ListDidsWebvhResultBody, VtaError> {
self.rpc(
did_management::LIST_DIDS_WEBVH,
serde_json::json!({
"context_id": context_id,
"server_id": server_id,
}),
did_management::LIST_DIDS_WEBVH_RESULT,
30,
|c, url| {
let mut u = format!("{url}/webvh/dids");
let mut sep = '?';
if let Some(ctx) = context_id {
u.push_str(&format!("{sep}context_id={ctx}"));
sep = '&';
}
if let Some(srv) = server_id {
u.push_str(&format!("{sep}server_id={srv}"));
}
c.get(u)
},
)
.await
}
pub async fn get_did_webvh(&self, did: &str) -> Result<crate::webvh::WebvhDidRecord, VtaError> {
self.rpc(
did_management::GET_DID_WEBVH,
serde_json::json!({ "did": did }),
did_management::GET_DID_WEBVH_RESULT,
30,
|c, url| c.get(format!("{url}/webvh/dids/{}", encode_path_segment(did))),
)
.await
}
pub async fn get_did_webvh_log(&self, did: &str) -> Result<GetDidLogResponse, VtaError> {
self.rpc(
did_management::GET_DID_WEBVH_LOG,
serde_json::json!({ "did": did }),
did_management::GET_DID_WEBVH_LOG_RESULT,
30,
|c, url| c.get(format!("{url}/webvh/dids/{}/log", encode_path_segment(did))),
)
.await
}
pub async fn delete_did_webvh(&self, did: &str) -> Result<(), VtaError> {
self.rpc_void(
did_management::DELETE_DID_WEBVH,
serde_json::json!({ "did": did }),
did_management::DELETE_DID_WEBVH_RESULT,
60,
|c, url| c.delete(format!("{url}/webvh/dids/{}", encode_path_segment(did))),
)
.await
}
pub async fn list_audit_logs(
&self,
params: &crate::protocols::audit_management::list::ListAuditLogsBody,
) -> Result<crate::protocols::audit_management::list::ListAuditLogsResultBody, VtaError> {
use crate::protocols::audit_management;
self.rpc(
audit_management::LIST_LOGS,
serde_json::to_value(params)?,
audit_management::LIST_LOGS_RESULT,
30,
|c, url| {
let mut qs = vec![
format!("page={}", params.page),
format!("page_size={}", params.page_size),
];
if let Some(from) = params.from {
qs.push(format!("from={from}"));
}
if let Some(to) = params.to {
qs.push(format!("to={to}"));
}
if let Some(ref action) = params.action {
qs.push(format!("action={action}"));
}
if let Some(ref actor) = params.actor {
qs.push(format!("actor={actor}"));
}
if let Some(ref outcome) = params.outcome {
qs.push(format!("outcome={outcome}"));
}
if let Some(ref ctx) = params.context_id {
qs.push(format!("context_id={ctx}"));
}
c.get(format!("{url}/audit/logs?{}", qs.join("&")))
},
)
.await
}
pub async fn get_audit_retention(
&self,
) -> Result<crate::protocols::audit_management::retention::RetentionResultBody, VtaError> {
use crate::protocols::audit_management;
self.rpc(
audit_management::GET_RETENTION,
serde_json::json!({}),
audit_management::GET_RETENTION_RESULT,
30,
|c, url| c.get(format!("{url}/audit/retention")),
)
.await
}
pub async fn update_audit_retention(
&self,
retention_days: u32,
) -> Result<crate::protocols::audit_management::retention::RetentionResultBody, VtaError> {
use crate::protocols::audit_management;
let body = audit_management::retention::UpdateRetentionBody { retention_days };
self.rpc(
audit_management::UPDATE_RETENTION,
serde_json::to_value(&body)?,
audit_management::UPDATE_RETENTION_RESULT,
30,
|c, url| c.patch(format!("{url}/audit/retention")).json(&body),
)
.await
}
pub async fn fetch_context_secrets(
&self,
context_id: &str,
) -> Result<Vec<affinidi_tdk::secrets_resolver::secrets::Secret>, VtaError> {
let page_size = 100u64;
let mut offset = 0u64;
let mut secrets = Vec::new();
loop {
let resp = self
.list_keys(offset, page_size, Some("active"), Some(context_id))
.await?;
if resp.keys.is_empty() {
break;
}
for key in &resp.keys {
let secret_resp = self.get_key_secret(&key.key_id).await?;
let secret = crate::did_key::secret_from_key_response(&secret_resp)?;
secrets.push(secret);
}
offset += resp.keys.len() as u64;
if offset >= resp.total {
break;
}
}
Ok(secrets)
}
pub async fn fetch_did_secrets_bundle(
&self,
context_id: &str,
) -> Result<crate::did_secrets::DidSecretsBundle, VtaError> {
let ctx = self.get_context(context_id).await?;
let did = ctx.did.ok_or_else(|| {
VtaError::Validation(format!("context '{context_id}' has no DID assigned"))
})?;
let page_size = 100u64;
let mut offset = 0u64;
let mut secrets = Vec::new();
loop {
let resp = self
.list_keys(offset, page_size, Some("active"), Some(context_id))
.await?;
if resp.keys.is_empty() {
break;
}
for key in &resp.keys {
let secret_resp = self.get_key_secret(&key.key_id).await?;
let mut entry = crate::did_secrets::SecretEntry::from(secret_resp);
if let Some(label) = key.label.as_deref()
&& (label.contains('#') || label.starts_with("did:"))
{
entry.key_id = label.to_string();
}
secrets.push(entry);
}
offset += resp.keys.len() as u64;
if offset >= resp.total {
break;
}
}
Ok(crate::did_secrets::DidSecretsBundle { did, secrets })
}
pub async fn check_auth(&self) -> Result<bool, VtaError> {
match &self.transport {
Transport::Rest {
client,
base_url,
auth,
} => {
let token = auth.lock().await.token.clone();
let req = client.get(format!("{base_url}/health/details"));
let resp = Self::with_auth_token(req, &token).send().await?;
Ok(resp.status().is_success())
}
#[cfg(feature = "session")]
Transport::DIDComm { .. } => {
Ok(true)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_encode_hash_in_did_fragment() {
assert_eq!(
encode_path_segment("did:key:z6Mk123#z6Mk123"),
"did:key:z6Mk123%23z6Mk123"
);
}
#[test]
fn test_encode_question_mark() {
assert_eq!(encode_path_segment("foo?bar"), "foo%3Fbar");
}
#[test]
fn test_encode_percent_is_escaped_first() {
assert_eq!(encode_path_segment("100%#done"), "100%25%23done");
}
#[test]
fn test_encode_colon_preserved() {
assert_eq!(encode_path_segment("did:key:z6Mk"), "did:key:z6Mk");
}
#[test]
fn test_encode_plain_string_unchanged() {
assert_eq!(encode_path_segment("simple-id"), "simple-id");
}
#[test]
fn test_encode_multiple_hashes() {
assert_eq!(encode_path_segment("a#b#c"), "a%23b%23c");
}
#[test]
fn test_encode_slash_in_derivation_path() {
assert_eq!(
encode_path_segment("m/44'/0'/0'/0"),
"m%2F44'%2F0'%2F0'%2F0"
);
}
#[test]
fn test_new_strips_trailing_slash() {
let client = VtaClient::new("http://localhost:3000/");
assert_eq!(client.base_url(), "http://localhost:3000");
}
#[test]
fn test_new_strips_multiple_trailing_slashes() {
let client = VtaClient::new("http://localhost:3000///");
assert_eq!(client.base_url(), "http://localhost:3000");
}
#[test]
fn test_new_no_trailing_slash_unchanged() {
let client = VtaClient::new("http://localhost:3000");
assert_eq!(client.base_url(), "http://localhost:3000");
}
#[tokio::test]
async fn test_new_token_initially_none() {
let client = VtaClient::new("http://example.com");
match &client.transport {
Transport::Rest { auth, .. } => assert!(auth.lock().await.token.is_none()),
#[cfg(feature = "session")]
_ => panic!("expected REST transport"),
}
}
#[tokio::test]
async fn test_set_token() {
let client = VtaClient::new("http://example.com");
client.set_token("my-jwt".to_string());
match &client.transport {
Transport::Rest { auth, .. } => {
assert_eq!(auth.lock().await.token.as_deref(), Some("my-jwt"));
}
#[cfg(feature = "session")]
_ => panic!("expected REST transport"),
}
}
#[test]
fn test_update_config_skips_none_fields() {
let req = UpdateConfigRequest {
vta_did: None,
vta_name: Some("Test".into()),
public_url: None,
};
let json = serde_json::to_value(&req).unwrap();
assert!(!json.as_object().unwrap().contains_key("vta_did"));
assert_eq!(json["vta_name"], "Test");
assert!(!json.as_object().unwrap().contains_key("public_url"));
}
#[test]
fn test_create_key_request_serialization() {
let req = CreateKeyRequest {
key_type: KeyType::Ed25519,
derivation_path: None,
key_id: None,
mnemonic: None,
label: Some("test key".into()),
context_id: Some("vta".into()),
};
let json = serde_json::to_value(&req).unwrap();
assert!(!json.as_object().unwrap().contains_key("derivation_path"));
assert!(!json.as_object().unwrap().contains_key("key_id"));
assert!(!json.as_object().unwrap().contains_key("mnemonic"));
assert_eq!(json["label"], "test key");
assert_eq!(json["context_id"], "vta");
}
#[test]
fn test_create_acl_request_serialization() {
let req = CreateAclRequest {
did: "did:key:z6Mk123".into(),
role: "admin".into(),
label: None,
allowed_contexts: vec!["vta".into()],
};
let json = serde_json::to_value(&req).unwrap();
assert_eq!(json["did"], "did:key:z6Mk123");
assert_eq!(json["role"], "admin");
assert!(!json.as_object().unwrap().contains_key("label"));
assert_eq!(json["allowed_contexts"], serde_json::json!(["vta"]));
}
#[test]
fn test_update_acl_request_all_none() {
let req = UpdateAclRequest {
role: None,
label: None,
allowed_contexts: None,
};
let json = serde_json::to_value(&req).unwrap();
let obj = json.as_object().unwrap();
assert!(obj.is_empty(), "all-None request should serialize to {{}}");
}
#[test]
fn test_health_response_deserialization() {
let json = r#"{"status":"ok","version":"0.1.0"}"#;
let resp: HealthResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.status, "ok");
assert_eq!(resp.version.as_deref(), Some("0.1.0"));
}
#[test]
fn test_health_response_minimal() {
let json = r#"{"status":"ok"}"#;
let resp: HealthResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.status, "ok");
assert_eq!(resp.version, None);
}
#[test]
fn test_error_response_deserialization() {
let json = r#"{"error":"not found"}"#;
let resp: ErrorResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.error, "not found");
}
#[test]
fn test_list_keys_response_deserialization() {
let json = r#"{"keys":[],"total":0}"#;
let resp: ListKeysResponse = serde_json::from_str(json).unwrap();
assert!(resp.keys.is_empty());
assert_eq!(resp.total, 0);
}
#[test]
fn test_generate_credentials_response_deserialization() {
let json = r#"{"did":"did:key:z6Mk123","credential":"abc123","role":"admin"}"#;
let resp: GenerateCredentialsResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.did, "did:key:z6Mk123");
assert_eq!(resp.credential, "abc123");
assert_eq!(resp.role, "admin");
}
#[test]
fn test_acl_list_response_deserialization() {
let json = r#"{"entries":[{"did":"did:key:z6Mk1","role":"admin","label":null,"allowed_contexts":[],"created_at":1700000000,"created_by":"setup"}]}"#;
let resp: AclListResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.entries.len(), 1);
assert_eq!(resp.entries[0].did, "did:key:z6Mk1");
assert_eq!(resp.entries[0].role, "admin");
assert!(resp.entries[0].allowed_contexts.is_empty());
}
#[test]
fn test_context_response_deserialization() {
let json = r#"{"id":"vta","name":"Verified Trust Agent","did":null,"description":null,"base_path":"m/26'/2'/0'","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"}"#;
let resp: ContextResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.id, "vta");
assert_eq!(resp.name, "Verified Trust Agent");
assert!(resp.did.is_none());
assert_eq!(resp.base_path, "m/26'/2'/0'");
}
}