pub mod auth;
pub mod cache;
pub use auth::authenticate;
pub use cache::SecretCache;
use crate::did_secrets::DidSecretsBundle;
use crate::error::VtaError;
use std::time::Duration;
const DEFAULT_STARTUP_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Clone, Debug)]
pub struct VtaAuthConfig {
pub credential: crate::credentials::CredentialBundle,
pub url_override: Option<String>,
pub timeout: Option<Duration>,
}
impl VtaAuthConfig {
pub fn new(credential: crate::credentials::CredentialBundle) -> Self {
Self {
credential,
url_override: None,
timeout: None,
}
}
}
#[derive(Clone)]
pub struct VtaContextConfig {
pub id: String,
#[cfg(feature = "session")]
pub mediator_did: Option<String>,
#[cfg(feature = "session")]
pub transport_preference: TransportPreference,
#[cfg(feature = "session")]
pub did_resolver: Option<std::sync::Arc<affinidi_did_resolver_cache_sdk::DIDCacheClient>>,
}
impl VtaContextConfig {
pub fn new(id: impl Into<String>) -> Self {
Self {
id: id.into(),
#[cfg(feature = "session")]
mediator_did: None,
#[cfg(feature = "session")]
transport_preference: TransportPreference::Auto,
#[cfg(feature = "session")]
did_resolver: None,
}
}
}
impl std::fmt::Debug for VtaContextConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut dbg = f.debug_struct("VtaContextConfig");
dbg.field("id", &self.id);
#[cfg(feature = "session")]
{
dbg.field("mediator_did", &self.mediator_did)
.field("transport_preference", &self.transport_preference)
.field(
"did_resolver",
&self.did_resolver.as_ref().map(|_| "Arc<DIDCacheClient>"),
);
}
dbg.finish()
}
}
#[derive(Clone, Debug)]
pub struct VtaServiceConfig {
pub auth: VtaAuthConfig,
pub context: VtaContextConfig,
}
impl VtaServiceConfig {
pub fn new(
credential: crate::credentials::CredentialBundle,
context: impl Into<String>,
) -> Self {
Self {
auth: VtaAuthConfig::new(credential),
context: VtaContextConfig::new(context),
}
}
}
#[cfg(feature = "session")]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum TransportPreference {
#[default]
Auto,
PreferRest,
DidCommOnly,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SecretSource {
Vta,
Cache,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FallbackReason {
AuthDenied,
Unreachable,
Timeout,
}
impl FallbackReason {
fn from_vta_error(err: &VtaIntegrationError) -> Self {
match err {
VtaIntegrationError::Vta(e) if e.is_auth() => FallbackReason::AuthDenied,
_ => FallbackReason::Unreachable,
}
}
}
pub struct StartupResult {
pub did: String,
pub bundle: DidSecretsBundle,
pub source: SecretSource,
pub client: Option<crate::client::VtaClient>,
}
#[derive(Debug)]
pub enum VtaIntegrationError {
NoCachedSecrets,
EmptySecretsBundle(String),
CacheError(String),
Vta(VtaError),
}
impl std::fmt::Display for VtaIntegrationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoCachedSecrets => write!(
f,
"VTA is unreachable and no cached secrets exist. \
Run the setup wizard or ensure the VTA is accessible for the first startup."
),
Self::EmptySecretsBundle(ctx) => write!(
f,
"VTA context '{ctx}' returned zero secrets. \
Provision keys via the setup wizard or VTA admin tools."
),
Self::CacheError(e) => write!(f, "secret cache error: {e}"),
Self::Vta(e) => write!(f, "VTA error: {e}"),
}
}
}
impl std::error::Error for VtaIntegrationError {}
impl From<VtaError> for VtaIntegrationError {
fn from(e: VtaError) -> Self {
Self::Vta(e)
}
}
pub async fn startup(
config: &VtaServiceConfig,
cache: &(impl SecretCache + ?Sized),
) -> Result<StartupResult, VtaIntegrationError> {
startup_with_reason(config, cache)
.await
.map(|(result, _reason)| result)
}
pub async fn startup_with_reason(
config: &VtaServiceConfig,
cache: &(impl SecretCache + ?Sized),
) -> Result<(StartupResult, Option<FallbackReason>), VtaIntegrationError> {
let timeout = config.auth.timeout.unwrap_or(DEFAULT_STARTUP_TIMEOUT);
let context_id = &config.context.id;
let vta_result = tokio::time::timeout(timeout, async {
let client = authenticate(config).await?;
let bundle = client
.fetch_did_secrets_bundle(context_id)
.await
.map_err(VtaIntegrationError::from)?;
Ok::<_, VtaIntegrationError>((client, bundle))
})
.await;
match vta_result {
Ok(Ok((client, bundle))) => {
if bundle.secrets.is_empty() {
client.shutdown().await;
return Err(VtaIntegrationError::EmptySecretsBundle(context_id.clone()));
}
if let Err(e) = cache.store(&bundle).await {
tracing::warn!("Failed to cache VTA secrets locally: {e}");
}
tracing::info!(
context = context_id,
secrets = bundle.secrets.len(),
"Loaded fresh secrets from VTA",
);
client.shutdown().await;
Ok((
StartupResult {
did: bundle.did.clone(),
bundle,
source: SecretSource::Vta,
client: Some(client),
},
None,
))
}
Ok(Err(e)) => {
let reason = FallbackReason::from_vta_error(&e);
tracing::warn!(
context = context_id,
error = %e,
reason = ?reason,
"VTA call failed; attempting fallback to last-known cached bundle",
);
load_from_cache(cache, context_id)
.await
.map(|result| (result, Some(reason)))
}
Err(_elapsed) => {
tracing::warn!(
context = context_id,
timeout_secs = timeout.as_secs(),
"VTA startup timed out; attempting fallback to last-known cached bundle",
);
load_from_cache(cache, context_id)
.await
.map(|result| (result, Some(FallbackReason::Timeout)))
}
}
}
async fn load_from_cache(
cache: &(impl SecretCache + ?Sized),
context: &str,
) -> Result<StartupResult, VtaIntegrationError> {
match cache.load().await {
Ok(Some(bundle)) => {
if bundle.secrets.is_empty() {
return Err(VtaIntegrationError::EmptySecretsBundle(context.to_string()));
}
tracing::warn!(
context = context,
secrets = bundle.secrets.len(),
"Booted from last-known cached bundle; keys may be stale. \
Will refresh on next successful VTA contact",
);
Ok(StartupResult {
did: bundle.did.clone(),
bundle,
source: SecretSource::Cache,
client: None,
})
}
Ok(None) => {
tracing::warn!(
context = context,
"No cached bundle found in local cache; returning NoCachedSecrets",
);
Err(VtaIntegrationError::NoCachedSecrets)
}
Err(e) => {
tracing::error!(
context = context,
error = %e,
"Failed to read cached bundle from local cache",
);
Err(VtaIntegrationError::CacheError(e.to_string()))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::did_secrets::{DidSecretsBundle, SecretEntry};
use crate::keys::KeyType;
use std::sync::Mutex;
struct MockSecretCache {
load_result: Mutex<Option<LoadOutcome>>,
last_stored: Mutex<Option<DidSecretsBundle>>,
}
enum LoadOutcome {
Some(DidSecretsBundle),
None,
Err(String),
}
impl MockSecretCache {
fn with_cached(bundle: DidSecretsBundle) -> Self {
Self {
load_result: Mutex::new(Some(LoadOutcome::Some(bundle))),
last_stored: Mutex::new(None),
}
}
fn empty() -> Self {
Self {
load_result: Mutex::new(Some(LoadOutcome::None)),
last_stored: Mutex::new(None),
}
}
fn failing(msg: &str) -> Self {
Self {
load_result: Mutex::new(Some(LoadOutcome::Err(msg.into()))),
last_stored: Mutex::new(None),
}
}
}
impl SecretCache for MockSecretCache {
fn store(
&self,
bundle: &DidSecretsBundle,
) -> impl std::future::Future<Output = Result<(), Box<dyn std::error::Error + Send + Sync>>> + Send
{
let mut slot = self.last_stored.lock().unwrap();
*slot = Some(bundle.clone());
async { Ok(()) }
}
fn load(
&self,
) -> impl std::future::Future<
Output = Result<Option<DidSecretsBundle>, Box<dyn std::error::Error + Send + Sync>>,
> + Send {
let taken = self.load_result.lock().unwrap().take();
async move {
match taken {
Some(LoadOutcome::Some(b)) => Ok(Some(b)),
Some(LoadOutcome::None) => Ok(None),
Some(LoadOutcome::Err(m)) => Err(m.into()),
None => Ok(None),
}
}
}
}
fn sample_bundle() -> DidSecretsBundle {
DidSecretsBundle {
did: "did:key:z6MkSampleIntegration".into(),
secrets: vec![SecretEntry {
key_id: "did:key:z6MkSampleIntegration#z6MkSampleIntegration".into(),
key_type: KeyType::Ed25519,
private_key_multibase: "z3weSampleSecret".into(),
}],
}
}
#[tokio::test]
async fn load_from_cache_returns_fresh_bundle_when_present() {
let cache = MockSecretCache::with_cached(sample_bundle());
let result = load_from_cache(&cache, "prod-mediator")
.await
.expect("cache hit returns StartupResult");
assert_eq!(result.source, SecretSource::Cache);
assert_eq!(result.did, "did:key:z6MkSampleIntegration");
assert_eq!(result.bundle.secrets.len(), 1);
assert!(
result.client.is_none(),
"Cache-source startup never carries a live VtaClient",
);
}
#[test]
fn fallback_reason_classifies_auth_errors() {
use crate::error::VtaError;
assert_eq!(
FallbackReason::from_vta_error(&VtaIntegrationError::Vta(VtaError::Forbidden(
"acl expired".into()
))),
FallbackReason::AuthDenied,
);
assert_eq!(
FallbackReason::from_vta_error(&VtaIntegrationError::Vta(VtaError::Auth(
"token rejected".into()
))),
FallbackReason::AuthDenied,
);
assert_eq!(
FallbackReason::from_vta_error(&VtaIntegrationError::Vta(VtaError::NotFound(
"no context".into()
))),
FallbackReason::Unreachable,
);
assert_eq!(
FallbackReason::from_vta_error(&VtaIntegrationError::NoCachedSecrets),
FallbackReason::Unreachable,
);
}
#[tokio::test]
async fn load_from_cache_empty_returns_no_cached_secrets() {
let cache = MockSecretCache::empty();
let result = load_from_cache(&cache, "prod-mediator").await;
match result {
Err(VtaIntegrationError::NoCachedSecrets) => {}
Err(other) => panic!("expected NoCachedSecrets, got {other:?}"),
Ok(_) => panic!("empty cache must be an error"),
}
}
#[tokio::test]
async fn load_from_cache_io_error_becomes_cache_error() {
let cache = MockSecretCache::failing("keyring unavailable");
let result = load_from_cache(&cache, "prod-mediator").await;
match result {
Err(VtaIntegrationError::CacheError(msg)) => {
assert!(msg.contains("keyring unavailable"), "got: {msg}")
}
Err(other) => panic!("expected CacheError, got {other:?}"),
Ok(_) => panic!("cache read error must propagate"),
}
}
#[tokio::test]
async fn load_from_cache_rejects_empty_bundle() {
let cache = MockSecretCache::with_cached(DidSecretsBundle {
did: "did:key:zEmpty".into(),
secrets: vec![],
});
let result = load_from_cache(&cache, "prod-mediator").await;
match result {
Err(VtaIntegrationError::EmptySecretsBundle(ctx)) => {
assert_eq!(ctx, "prod-mediator")
}
Err(other) => panic!("expected EmptySecretsBundle, got {other:?}"),
Ok(_) => panic!("empty-secrets bundle must be rejected"),
}
}
}