use crate::{
config::{
protected_config::ProtectedConfig,
secured_config::{KeyInfoConfig, KeySourceMaterial, ProtectionMethod},
},
errors::OpenVTCError,
};
use affinidi_tdk::secrets_resolver::secrets::Secret;
use argon2::{Algorithm, Argon2, Params, Version};
use chrono::{DateTime, TimeDelta, Utc};
use ed25519_dalek_bip32::ExtendedSigningKey;
use secrecy::{ExposeSecret, SecretBox, SecretString};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{
collections::{BTreeMap, HashMap},
fmt::Display,
};
pub mod account;
pub mod community_context;
pub mod context_path;
pub mod did;
pub mod integrity;
pub mod keys;
pub mod loading;
pub mod protected_config;
pub mod public_config;
pub mod saving;
pub mod secured_config;
pub use public_config::PublicConfig;
pub fn derive_passphrase_key(passphrase: &[u8], info: &[u8]) -> Result<[u8; 32], OpenVTCError> {
let salt = Sha256::digest(info);
derive_argon2_key(passphrase, &salt)
}
pub fn derive_passphrase_key_v2(passphrase: &[u8], salt: &[u8]) -> Result<[u8; 32], OpenVTCError> {
derive_argon2_key(passphrase, salt)
}
pub async fn derive_passphrase_key_blocking(
passphrase: Vec<u8>,
info: Vec<u8>,
) -> Result<[u8; 32], OpenVTCError> {
let passphrase = zeroize::Zeroizing::new(passphrase);
tokio::task::spawn_blocking(move || derive_passphrase_key(&passphrase, &info))
.await
.map_err(|e| OpenVTCError::Config(format!("Argon2 derivation task panicked: {e}")))?
}
fn derive_argon2_key(passphrase: &[u8], salt: &[u8]) -> Result<[u8; 32], OpenVTCError> {
let mut key = [0u8; 32];
let params = Params::new(128 * 1024, 4, 1, Some(32))
.map_err(|e| OpenVTCError::Config(format!("Invalid Argon2 parameters: {e}")))?;
Argon2::new(Algorithm::Argon2id, Version::V0x13, params)
.hash_password_into(passphrase, salt, &mut key)
.map_err(|e| OpenVTCError::Config(format!("Argon2 key derivation failed: {e}")))?;
Ok(key)
}
pub const MIN_PASSPHRASE_LENGTH: usize = 8;
pub fn validate_passphrase(passphrase: &str) -> Result<(), OpenVTCError> {
if passphrase.len() < MIN_PASSPHRASE_LENGTH {
return Err(OpenVTCError::Config(format!(
"Passphrase must be at least {MIN_PASSPHRASE_LENGTH} characters (got {})",
passphrase.len()
)));
}
Ok(())
}
pub struct UnlockCode(pub(crate) SecretBox<Vec<u8>>);
impl UnlockCode {
pub fn from_string(s: &str) -> Result<Self, OpenVTCError> {
validate_passphrase(s)?;
let key = derive_passphrase_key(s.as_bytes(), b"openvtc-unlock-code-v1")?;
Ok(UnlockCode(SecretBox::new(Box::new(key.to_vec()))))
}
}
#[derive(Clone, Serialize, Deserialize, Debug, Default)]
#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
pub enum ConfigProtectionType {
Token(String),
#[default]
Encrypted,
Plaintext,
}
#[cfg(feature = "openpgp-card")]
pub trait TokenInteractions: Send + Sync {
fn touch_notify(&self);
fn touch_completed(&self);
}
pub enum KeyBackend {
Bip32 {
root: ExtendedSigningKey,
seed: SecretString,
},
Vta {
credential_bundle: SecretString,
credential_did: String,
credential_private_key: SecretString,
vta_did: String,
vta_url: String,
mediator_did: Option<String>,
encryption_seed: SecretBox<Vec<u8>>,
},
}
impl std::fmt::Debug for KeyBackend {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
KeyBackend::Bip32 { .. } => f.debug_struct("KeyBackend::Bip32").finish_non_exhaustive(),
KeyBackend::Vta {
credential_did,
vta_did,
vta_url,
..
} => f
.debug_struct("KeyBackend::Vta")
.field("credential_did", credential_did)
.field("vta_did", vta_did)
.field("vta_url", vta_url)
.finish_non_exhaustive(),
}
}
}
#[derive(Debug)]
pub struct Config {
pub public: public_config::PublicConfig,
pub private: ProtectedConfig,
pub key_backend: KeyBackend,
pub key_info: HashMap<String, KeyInfoConfig>,
pub protection_method: ProtectionMethod,
#[cfg(feature = "openpgp-card")]
pub token_admin_pin: Option<SecretString>,
#[cfg(feature = "openpgp-card")]
pub token_user_pin: SecretString,
pub unlock_code: Option<SecretBox<Vec<u8>>>,
pub account: account::Account,
pub protected_key: Option<SecretString>,
pub integrity: integrity::LoadIntegrity,
pub identities: BTreeMap<account::PersonaId, crate::identity::IdentityContext>,
pub active_persona: Option<account::PersonaId>,
pub runtime_trust_overrides: Option<OriginalTrustAnchors>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct OriginalTrustAnchors {
pub vta_url: Option<String>,
pub vta_did: Option<String>,
}
#[derive(Deserialize, Serialize)]
pub struct ExportedConfig {
pub pc: public_config::PublicConfig,
pub sc: secured_config::SecuredConfig,
}
const PROFILE_LABEL_DID_WIDTH: usize = 32;
pub(crate) fn membership_profile_label(
membership: Option<&account::CommunityRecord>,
persona_did: &str,
agent_name: impl FnOnce(&str) -> Option<String>,
) -> String {
let who = crate::display::shorten_for_display(persona_did, PROFILE_LABEL_DID_WIDTH);
let Some(c) = membership else {
return format!("Persona ({who})");
};
let community = c
.display_name
.clone()
.or_else(|| agent_name(&c.vtc_did))
.unwrap_or_else(|| c.vtc_did.clone());
format!("{community} ({who})")
}
pub(crate) fn decode_protected_key(key: &SecretString) -> Result<SecretBox<Vec<u8>>, OpenVTCError> {
use base64::{Engine, prelude::BASE64_URL_SAFE_NO_PAD};
let bytes = BASE64_URL_SAFE_NO_PAD
.decode(key.expose_secret())
.map_err(|e| OpenVTCError::Decrypt(format!("stored config key is not base64url: {e}")))?;
if bytes.len() < 32 {
return Err(OpenVTCError::Decrypt(
"stored config key is shorter than 32 bytes".to_string(),
));
}
Ok(SecretBox::new(Box::new(bytes)))
}
impl Config {
pub fn get_encryption_seed(&self) -> Result<SecretBox<Vec<u8>>, OpenVTCError> {
if let Some(key) = &self.protected_key {
return decode_protected_key(key);
}
self.legacy_encryption_seed()
}
pub(crate) fn legacy_encryption_seed(&self) -> Result<SecretBox<Vec<u8>>, OpenVTCError> {
match &self.key_backend {
KeyBackend::Bip32 { root, .. } => ProtectedConfig::get_seed(root, "m/0'/0'/0'"),
KeyBackend::Vta {
encryption_seed, ..
} => Ok(SecretBox::new(Box::new(
encryption_seed.expose_secret().to_vec(),
))),
}
}
pub fn active_identity(&self) -> Option<&crate::identity::IdentityContext> {
self.active_persona
.and_then(|id| self.identities.get(&id))
.or_else(|| self.identities.values().next())
}
pub fn set_active_persona(&mut self, persona: Option<account::PersonaId>) {
self.active_persona = persona;
}
pub fn persona_did(&self) -> &str {
self.active_identity().map(|i| i.did.as_str()).unwrap_or("")
}
pub fn persona_did_arc(&self) -> std::sync::Arc<String> {
std::sync::Arc::new(self.persona_did().to_string())
}
pub fn mediator_did(&self) -> &str {
self.active_identity()
.and_then(|i| i.mediator_did.as_deref())
.unwrap_or("")
}
pub fn persona_profile_label(&self) -> String {
match self.active_identity().map(|i| i.persona_id) {
Some(pid) => self.persona_profile_label_for(pid),
None => "Persona".to_string(),
}
}
pub fn persona_profile_label_for(&self, persona_id: account::PersonaId) -> String {
membership_profile_label(
self.account
.memberships()
.find(|c| c.persona_ref == persona_id),
self.identities
.get(&persona_id)
.map_or("", |identity| identity.did.as_str()),
|did| self.agent_name_for(did).map(ToString::to_string),
)
}
#[must_use]
pub fn agent_name_for(&self, did: &str) -> Option<&str> {
self.private.cached_agent_name(did)
}
pub fn set_cached_agent_name(
&mut self,
did: &str,
name: Option<String>,
now: chrono::DateTime<chrono::Utc>,
) {
self.private.agent_names.insert(
did.to_string(),
crate::agent_name::CachedAgentName {
name,
checked_at: now,
},
);
}
#[must_use]
pub fn agent_name_refresh_targets(&self, now: chrono::DateTime<chrono::Utc>) -> Vec<String> {
let mut dids: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for persona in self.account.personas.values() {
dids.insert(persona.did.clone());
}
for community in self.account.memberships() {
dids.insert(community.vtc_did.clone());
}
for rel in self.private.relationships.relationships.values() {
dids.insert(rel.remote_p_did.to_string());
}
for contact in self.private.contacts.contacts.keys() {
dids.insert(contact.to_string());
}
for persona in self.account.personas.values() {
if let Some(mediator) = persona.mediator_did.as_deref()
&& mediator.starts_with("did:")
{
dids.insert(mediator.to_string());
}
}
if let KeyBackend::Vta { vta_did, .. } = &self.key_backend
&& vta_did.starts_with("did:")
{
dids.insert(vta_did.clone());
}
dids.into_iter()
.filter(|did| {
self.private
.agent_names
.get(did)
.is_none_or(|cached| cached.is_stale(now))
})
.collect()
}
#[must_use = "a false return means the mediator DID was NOT set; do not report success"]
pub fn set_active_mediator_did(&mut self, did: &str) -> bool {
let Some(id) = self.active_identity().map(|i| i.persona_id) else {
return false;
};
let Some(persona) = self.account.personas.get_mut(&id) else {
return false;
};
persona.mediator_did = Some(did.to_string());
if let Some(ctx) = self.identities.get_mut(&id) {
ctx.mediator_did = Some(did.to_string());
}
true
}
#[must_use = "a false return means the mediator DID was NOT set"]
pub fn set_active_mediator_did_runtime(&mut self, did: &str) -> bool {
let Some(id) = self.active_identity().map(|i| i.persona_id) else {
return false;
};
match self.identities.get_mut(&id) {
Some(ctx) => {
ctx.mediator_did = Some(did.to_string());
true
}
None => false,
}
}
#[must_use = "a false return means the VTA URL was NOT overridden"]
pub fn override_vta_url_runtime(&mut self, url: &str) -> bool {
let KeyBackend::Vta { vta_url, .. } = &mut self.key_backend else {
return false;
};
let original = std::mem::replace(vta_url, url.to_string());
self.runtime_trust_overrides
.get_or_insert_with(OriginalTrustAnchors::default)
.vta_url
.get_or_insert(original);
true
}
#[must_use = "a false return means the VTA DID was NOT overridden"]
pub fn override_vta_did_runtime(&mut self, did: &str) -> bool {
let KeyBackend::Vta { vta_did, .. } = &mut self.key_backend else {
return false;
};
let original = std::mem::replace(vta_did, did.to_string());
self.runtime_trust_overrides
.get_or_insert_with(OriginalTrustAnchors::default)
.vta_did
.get_or_insert(original);
true
}
pub fn persisted_vta_anchor(&self) -> Option<(&str, &str)> {
let KeyBackend::Vta {
vta_url, vta_did, ..
} = &self.key_backend
else {
return None;
};
let originals = self.runtime_trust_overrides.as_ref();
Some((
originals
.and_then(|o| o.vta_url.as_deref())
.unwrap_or(vta_url),
originals
.and_then(|o| o.vta_did.as_deref())
.unwrap_or(vta_did),
))
}
pub fn is_persona_did(&self, did: &str) -> bool {
self.identities.values().any(|i| i.did == did)
}
#[must_use]
pub fn persona_dids(&self) -> Vec<String> {
self.identities.values().map(|i| i.did.clone()).collect()
}
}
pub async fn build_runtime_vta_client(
backend: &KeyBackend,
) -> Result<vta_sdk::client::VtaClient, OpenVTCError> {
let KeyBackend::Vta {
vta_url,
vta_did,
credential_did,
credential_private_key,
mediator_did,
..
} = backend
else {
return Err(OpenVTCError::Config(
"build_runtime_vta_client called on a non-VTA key backend".to_string(),
));
};
connect_runtime_vta_client(
vta_url,
vta_did,
credential_did,
credential_private_key.expose_secret(),
mediator_did.as_deref(),
)
.await
}
async fn connect_runtime_vta_client(
vta_url: &str,
vta_did: &str,
credential_did: &str,
private_key_multibase: &str,
mediator_did: Option<&str>,
) -> Result<vta_sdk::client::VtaClient, OpenVTCError> {
let mut client = vta_sdk::client::VtaClient::connect_auto(vta_sdk::client::AutoConnect {
vta_url,
vta_did,
credential_did,
private_key_multibase,
mediator_did,
})
.await
.map(|connected| connected.client)
.map_err(map_connect_error)?;
enable_tsp_if_advertised(&mut client, vta_did).await;
Ok(client)
}
pub struct RuntimeVtaConnect {
vta_url: String,
vta_did: String,
credential_did: String,
credential_private_key: SecretString,
mediator_did: Option<String>,
}
impl RuntimeVtaConnect {
#[must_use]
pub fn from_backend(backend: &KeyBackend) -> Option<Self> {
match backend {
KeyBackend::Vta {
vta_url,
vta_did,
credential_did,
credential_private_key,
mediator_did,
..
} => Some(Self {
vta_url: vta_url.clone(),
vta_did: vta_did.clone(),
credential_did: credential_did.clone(),
credential_private_key: SecretString::from(
credential_private_key.expose_secret().to_string(),
),
mediator_did: mediator_did.clone(),
}),
KeyBackend::Bip32 { .. } => None,
}
}
pub async fn connect(&self) -> Result<vta_sdk::client::VtaClient, OpenVTCError> {
connect_runtime_vta_client(
&self.vta_url,
&self.vta_did,
&self.credential_did,
self.credential_private_key.expose_secret(),
self.mediator_did.as_deref(),
)
.await
}
}
impl std::fmt::Debug for RuntimeVtaConnect {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RuntimeVtaConnect")
.field("vta_did", &self.vta_did)
.field("credential_did", &self.credential_did)
.finish_non_exhaustive()
}
}
const TSP_DISCOVERY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
async fn enable_tsp_if_advertised(client: &mut vta_sdk::client::VtaClient, vta_did: &str) {
let resolver = match affinidi_did_resolver_cache_sdk::DIDCacheClient::new(
affinidi_did_resolver_cache_sdk::config::DIDCacheConfigBuilder::default().build(),
)
.await
{
Ok(resolver) => resolver,
Err(e) => {
tracing::debug!("TSP discovery resolver init failed ({e}); staying on DIDComm");
return;
}
};
enable_tsp_with_resolver(client, vta_did, &resolver).await;
}
pub async fn peer_tsp_mediator(peer_did: &str) -> Option<String> {
let resolver = match affinidi_did_resolver_cache_sdk::DIDCacheClient::new(
affinidi_did_resolver_cache_sdk::config::DIDCacheConfigBuilder::default().build(),
)
.await
{
Ok(resolver) => resolver,
Err(e) => {
tracing::debug!(
peer = %peer_did,
"TSP discovery resolver init failed ({e}); using DIDComm"
);
return None;
}
};
match discover_tsp_mediator(peer_did, &resolver).await {
TspDiscovery::Advertised(mediator) => {
tracing::info!(
peer = %peer_did,
mediator = %mediator,
"peer advertises #tsp — sending trust tasks over TSP"
);
Some(mediator)
}
TspDiscovery::NotAdvertised => {
tracing::debug!(peer = %peer_did, "peer advertises no #tsp — using DIDComm");
None
}
TspDiscovery::Unavailable(reason) => {
tracing::warn!(
peer = %peer_did,
reason = %reason,
"could not determine whether the peer offers TSP — using DIDComm"
);
None
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PeerTransports {
pub tsp_mediator: Option<String>,
pub didcomm_mediator: Option<String>,
}
impl PeerTransports {
#[must_use]
pub fn any(&self) -> bool {
self.tsp_mediator.is_some() || self.didcomm_mediator.is_some()
}
#[must_use]
pub fn preferred(&self) -> Option<crate::didcomm::MessagingTransport> {
if self.tsp_mediator.is_some() {
Some(crate::didcomm::MessagingTransport::Tsp)
} else if self.didcomm_mediator.is_some() {
Some(crate::didcomm::MessagingTransport::DidComm)
} else {
None
}
}
}
pub async fn peer_messaging_transports(peer_did: &str) -> PeerTransports {
let Ok(resolver) = affinidi_did_resolver_cache_sdk::DIDCacheClient::new(
affinidi_did_resolver_cache_sdk::config::DIDCacheConfigBuilder::default().build(),
)
.await
else {
tracing::debug!(peer = %peer_did, "transport discovery resolver init failed");
return PeerTransports {
tsp_mediator: None,
didcomm_mediator: None,
};
};
match tokio::time::timeout(
TSP_DISCOVERY_TIMEOUT,
vta_sdk::provision_client::resolve_vta_with_resolver(peer_did, &resolver),
)
.await
{
Ok(Ok(resolved)) => PeerTransports {
tsp_mediator: resolved.tsp_mediator_did,
didcomm_mediator: resolved.mediator_did,
},
Ok(Err(e)) => {
tracing::debug!(peer = %peer_did, error = %e, "transport discovery failed");
PeerTransports::default()
}
Err(_) => {
tracing::debug!(peer = %peer_did, "transport discovery timed out");
PeerTransports::default()
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum TspCarriage {
Serves,
Absent,
Unknown(String),
}
pub(crate) async fn mediator_tsp_carriage(
mediator_did: &str,
resolver: &affinidi_did_resolver_cache_sdk::DIDCacheClient,
) -> TspCarriage {
let resolved =
match tokio::time::timeout(TSP_DISCOVERY_TIMEOUT, resolver.resolve(mediator_did)).await {
Ok(Ok(resolved)) => resolved,
Ok(Err(e)) => return TspCarriage::Unknown(e.to_string()),
Err(_) => {
return TspCarriage::Unknown(format!(
"timed out after {}s",
TSP_DISCOVERY_TIMEOUT.as_secs()
));
}
};
let doc = match serde_json::to_value(&resolved.doc) {
Ok(doc) => doc,
Err(e) => return TspCarriage::Unknown(format!("could not re-serialize document: {e}")),
};
if vta_sdk::protocol::matching::ServiceCapabilities::from_did_document(&doc)
.tsp
.is_some()
{
TspCarriage::Serves
} else {
TspCarriage::Absent
}
}
pub async fn our_mediator_carries_tsp(mediator_did: &str) -> Option<bool> {
if mediator_did.is_empty() {
return None;
}
let resolver = match affinidi_did_resolver_cache_sdk::DIDCacheClient::new(
affinidi_did_resolver_cache_sdk::config::DIDCacheConfigBuilder::default().build(),
)
.await
{
Ok(resolver) => resolver,
Err(e) => {
tracing::debug!(
mediator = %mediator_did,
"TSP carriage resolver init failed ({e})"
);
return None;
}
};
match mediator_tsp_carriage(mediator_did, &resolver).await {
TspCarriage::Serves => Some(true),
TspCarriage::Absent => {
tracing::debug!(
mediator = %mediator_did,
"our mediator advertises no TSPTransport — TSP sends would be rejected"
);
Some(false)
}
TspCarriage::Unknown(reason) => {
tracing::warn!(
mediator = %mediator_did,
reason = %reason,
"could not determine whether our mediator carries TSP"
);
None
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum TspDiscovery {
Advertised(String),
NotAdvertised,
Unavailable(String),
}
pub(crate) async fn discover_tsp_mediator(
vta_did: &str,
resolver: &affinidi_did_resolver_cache_sdk::DIDCacheClient,
) -> TspDiscovery {
let resolved = match tokio::time::timeout(
TSP_DISCOVERY_TIMEOUT,
vta_sdk::provision_client::resolve_vta_with_resolver(vta_did, resolver),
)
.await
{
Ok(Ok(resolved)) => resolved,
Ok(Err(e)) => return TspDiscovery::Unavailable(e.to_string()),
Err(_) => {
return TspDiscovery::Unavailable(format!(
"timed out after {}s",
TSP_DISCOVERY_TIMEOUT.as_secs()
));
}
};
match resolved.tsp_mediator_did {
Some(mediator) => TspDiscovery::Advertised(mediator),
None => TspDiscovery::NotAdvertised,
}
}
async fn enable_tsp_with_resolver(
client: &mut vta_sdk::client::VtaClient,
vta_did: &str,
resolver: &affinidi_did_resolver_cache_sdk::DIDCacheClient,
) {
match discover_tsp_mediator(vta_did, resolver).await {
TspDiscovery::Advertised(mediator) => match client.enable_tsp_trust_tasks(&mediator) {
Ok(()) => {
match client.relate_tsp_trust_task_leg(vta_did).await {
Ok(()) => tracing::info!(
"trust tasks routed over TSP (mediator {mediator}); relationship formed"
),
Err(e) => {
tracing::debug!(
"TSP relate to {vta_did} failed ({e}); this session's trust tasks \
may fall to the §7.2.2 gate until the next VTA connect re-relates"
);
}
}
}
Err(e) => {
tracing::debug!("could not enable the TSP leg ({e}); trust tasks stay on DIDComm")
}
},
TspDiscovery::NotAdvertised => {
tracing::debug!("{vta_did} advertises no #tsp service; trust tasks stay on DIDComm")
}
TspDiscovery::Unavailable(reason) => {
tracing::debug!("TSP discovery for {vta_did} failed ({reason}); staying on DIDComm")
}
}
}
fn map_connect_error(e: vta_sdk::error::VtaError) -> OpenVTCError {
use vta_sdk::error::VtaError;
match e {
VtaError::Validation(msg) => OpenVTCError::Config(msg),
VtaError::Auth(msg) => OpenVTCError::Auth(format!("VTA authentication failed: {msg}")),
other => OpenVTCError::Vta(format!("VTA connection failed: {other}")),
}
}
pub async fn with_runtime_vta_client<F, Fut, T, E>(backend: &KeyBackend, f: F) -> Result<T, E>
where
F: FnOnce(vta_sdk::client::VtaClient) -> Fut,
Fut: std::future::Future<Output = Result<T, E>>,
E: From<OpenVTCError>,
{
let client = build_runtime_vta_client(backend).await?;
let result = f(client.clone()).await;
client.shutdown().await;
result
}
#[derive(Clone, Serialize, Default, Deserialize, Debug)]
pub enum KeyTypes {
PersonaSigning,
PersonaAuthentication,
PersonaEncryption,
PersonaOther,
RelationshipVerification,
RelationshipEncryption,
WebVHManagement,
#[default]
Unknown,
}
impl Display for KeyTypes {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
KeyTypes::PersonaSigning => "Persona Signing Key",
KeyTypes::PersonaAuthentication => "Persona Authentication Key",
KeyTypes::PersonaEncryption => "Persona Encryption Key",
KeyTypes::PersonaOther => "Persona Other Key",
KeyTypes::RelationshipVerification => "Relationship Verification Key",
KeyTypes::RelationshipEncryption => "Relationship Encryption Key",
KeyTypes::WebVHManagement => "Web VH Management Key",
KeyTypes::Unknown => "Unknown Key Type",
};
write!(f, "{}", s)
}
}
#[derive(Clone)]
pub struct PersonaDIDKeys {
pub signing: KeyInfo,
pub authentication: KeyInfo,
pub decryption: KeyInfo,
}
impl std::fmt::Debug for PersonaDIDKeys {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PersonaDIDKeys")
.field("signing", &"[REDACTED]")
.field("authentication", &"[REDACTED]")
.field("decryption", &"[REDACTED]")
.finish()
}
}
#[derive(Clone)]
pub struct KeyInfo {
pub secret: Secret,
pub source: KeySourceMaterial,
pub expiry: Option<TimeDelta>,
pub created: DateTime<Utc>,
}
impl std::fmt::Debug for KeyInfo {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("KeyInfo")
.field("secret", &"[REDACTED]")
.field("source", &self.source)
.field("expiry", &self.expiry)
.field("created", &self.created)
.finish()
}
}
#[cfg(test)]
mod tsp_discovery_tests {
use super::{TspDiscovery, discover_tsp_mediator};
use affinidi_did_resolver_cache_sdk::{DIDCacheClient, config::DIDCacheConfigBuilder};
use serde_json::json;
const VTA: &str = "did:web:vta.example";
const MEDIATOR: &str = "did:web:mediator.example";
async fn resolver_serving(services: serde_json::Value) -> DIDCacheClient {
let mut client = DIDCacheClient::new(DIDCacheConfigBuilder::default().build())
.await
.expect("local DID cache");
let doc = json!({
"@context": ["https://www.w3.org/ns/did/v1"],
"id": VTA,
"service": services,
});
client
.add_did_document(VTA, serde_json::from_value(doc).expect("fixture document"))
.await;
client
}
#[tokio::test]
async fn a_tsp_advertising_vta_yields_its_mediator() {
let resolver = resolver_serving(json!([
{ "id": format!("{VTA}#tsp"), "type": "TSPTransport", "serviceEndpoint": MEDIATOR },
{ "id": format!("{VTA}#vta-didcomm"), "type": "DIDCommMessaging", "serviceEndpoint": MEDIATOR },
]))
.await;
assert_eq!(
discover_tsp_mediator(VTA, &resolver).await,
TspDiscovery::Advertised(MEDIATOR.to_string()),
);
}
#[tokio::test]
async fn a_didcomm_only_vta_is_not_advertised_rather_than_failed() {
let resolver = resolver_serving(json!([
{ "id": format!("{VTA}#vta-didcomm"), "type": "DIDCommMessaging", "serviceEndpoint": MEDIATOR },
]))
.await;
assert_eq!(
discover_tsp_mediator(VTA, &resolver).await,
TspDiscovery::NotAdvertised,
);
}
#[tokio::test]
async fn an_unresolvable_vta_is_unavailable_not_unadvertised() {
let resolver = DIDCacheClient::new(DIDCacheConfigBuilder::default().build())
.await
.expect("local DID cache");
assert!(matches!(
discover_tsp_mediator("did:example:nothing-seeded", &resolver).await,
TspDiscovery::Unavailable(_)
));
}
#[tokio::test]
async fn an_unresolvable_did_web_falls_back_rather_than_failing() {
let resolver = DIDCacheClient::new(DIDCacheConfigBuilder::default().build())
.await
.expect("local DID cache");
assert_eq!(
discover_tsp_mediator("did:web:127.0.0.1%3A1", &resolver).await,
TspDiscovery::NotAdvertised,
"the URL fallback makes this look resolved"
);
}
#[tokio::test]
async fn a_non_did_tsp_endpoint_is_not_advertised() {
let resolver = resolver_serving(json!([
{ "id": format!("{VTA}#tsp"), "type": "TSPTransport", "serviceEndpoint": "https://not-a-did.example" },
{ "id": format!("{VTA}#vta-didcomm"), "type": "DIDCommMessaging", "serviceEndpoint": MEDIATOR },
]))
.await;
assert_eq!(
discover_tsp_mediator(VTA, &resolver).await,
TspDiscovery::NotAdvertised,
);
}
}
#[cfg(test)]
mod mediator_tsp_carriage_tests {
use super::{TspCarriage, mediator_tsp_carriage};
use affinidi_did_resolver_cache_sdk::{DIDCacheClient, config::DIDCacheConfigBuilder};
use serde_json::json;
const MEDIATOR: &str = "did:web:mediator.example";
async fn resolver_serving(services: serde_json::Value) -> DIDCacheClient {
let mut client = DIDCacheClient::new(DIDCacheConfigBuilder::default().build())
.await
.expect("local DID cache");
let doc = json!({
"@context": ["https://www.w3.org/ns/did/v1"],
"id": MEDIATOR,
"service": services,
});
client
.add_did_document(
MEDIATOR,
serde_json::from_value(doc).expect("fixture document"),
)
.await;
client
}
#[tokio::test]
async fn a_tsp_mediator_serves() {
let resolver = resolver_serving(json!([
{ "id": format!("{MEDIATOR}#tsp"), "type": "TSPTransport", "serviceEndpoint": "https://mediator.example/mediator/v1" },
{ "id": format!("{MEDIATOR}#service"), "type": ["DIDCommMessaging"], "serviceEndpoint": [{ "uri": "https://mediator.example/mediator/v1", "accept": ["didcomm/v2"] }] },
]))
.await;
assert_eq!(
mediator_tsp_carriage(MEDIATOR, &resolver).await,
TspCarriage::Serves,
);
}
#[tokio::test]
async fn a_didcomm_only_mediator_is_absent_not_unknown() {
let resolver = resolver_serving(json!([
{ "id": format!("{MEDIATOR}#service"), "type": ["DIDCommMessaging"], "serviceEndpoint": [{ "uri": "https://mediator.example/mediator/v1", "accept": ["didcomm/v2"] }] },
{ "id": format!("{MEDIATOR}#auth"), "type": ["Authentication"], "serviceEndpoint": "https://mediator.example/mediator/v1/authenticate" },
]))
.await;
assert_eq!(
mediator_tsp_carriage(MEDIATOR, &resolver).await,
TspCarriage::Absent,
);
}
#[tokio::test]
async fn carriage_is_matched_on_type_not_id_fragment() {
let resolver = resolver_serving(json!([
{ "id": format!("{MEDIATOR}#tsp-transport"), "type": "TSPTransport", "serviceEndpoint": "https://mediator.example/mediator/v1" },
]))
.await;
assert_eq!(
mediator_tsp_carriage(MEDIATOR, &resolver).await,
TspCarriage::Serves,
);
}
#[tokio::test]
async fn an_unresolvable_mediator_is_unknown() {
let resolver = DIDCacheClient::new(DIDCacheConfigBuilder::default().build())
.await
.expect("local DID cache");
assert!(matches!(
mediator_tsp_carriage("did:example:nothing-seeded", &resolver).await,
TspCarriage::Unknown(_)
));
}
}
#[cfg(test)]
mod membership_profile_label_tests {
use super::membership_profile_label;
use crate::config::account::{CommunityRecord, PersonaId};
const VTC_DID: &str = "did:webvh:QmScidCommunityCCCCCCCCCCCC:vtc.example:acme";
const PERSONA_DID: &str = "did:webvh:QmScidPersonaAAAAAAAAAAAAAA:vtc.example:alice";
const PERSONA_SHORT: &str = "did:webv\u{2026}AAAA:vtc.example:alice";
fn membership(display_name: Option<&str>) -> CommunityRecord {
CommunityRecord::new_pending(
VTC_DID.to_string(),
display_name.map(ToString::to_string),
"openvtc-glenn/qmxi1pzd4nev".to_string(),
PersonaId::default(),
uuid::Uuid::nil(),
chrono::Utc::now(),
)
}
#[test]
fn a_display_name_is_preferred() {
let c = membership(Some("Acme Corp"));
let label = membership_profile_label(Some(&c), PERSONA_DID, |_| {
Some("vtc.example/@acme".to_string())
});
assert_eq!(label, format!("Acme Corp ({PERSONA_SHORT})"));
}
#[test]
fn a_verified_agent_name_is_used_when_there_is_no_display_name() {
let c = membership(None);
let label = membership_profile_label(Some(&c), PERSONA_DID, |did| {
assert_eq!(did, VTC_DID, "the VTC DID is what gets looked up");
Some("vtc.example/@acme".to_string())
});
assert_eq!(label, format!("vtc.example/@acme ({PERSONA_SHORT})"));
}
#[test]
fn an_unverified_community_falls_back_to_the_did() {
let c = membership(None);
assert_eq!(
membership_profile_label(Some(&c), PERSONA_DID, |_| None),
format!("{VTC_DID} ({PERSONA_SHORT})")
);
}
#[test]
fn no_membership_is_still_identified_by_its_did() {
assert_eq!(
membership_profile_label(None, PERSONA_DID, |_| None),
format!("Persona ({PERSONA_SHORT})")
);
}
#[test]
fn two_personas_in_one_community_are_distinguishable() {
const OTHER_PERSONA: &str = "did:webvh:QmScidPersonaBBBBBBBBBBBBBB:vtc.example:bob";
let c = membership(Some("Acme Corp"));
let one = membership_profile_label(Some(&c), PERSONA_DID, |_| None);
let two = membership_profile_label(Some(&c), OTHER_PERSONA, |_| None);
assert_ne!(
one, two,
"same community, different identity: {one} / {two}"
);
assert!(one.starts_with("Acme Corp ("), "{one}");
assert!(two.starts_with("Acme Corp ("), "{two}");
}
#[test]
fn a_short_did_is_not_truncated() {
const SHORT: &str = "did:key:z6MkShort";
assert_eq!(
membership_profile_label(None, SHORT, |_| None),
format!("Persona ({SHORT})")
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use secrecy::ExposeSecret;
#[test]
fn test_derive_passphrase_key_deterministic() {
let key1 = derive_passphrase_key(b"my-passphrase", b"info-label").unwrap();
let key2 = derive_passphrase_key(b"my-passphrase", b"info-label").unwrap();
assert_eq!(key1, key2, "Same inputs must produce the same derived key");
}
#[test]
fn test_derive_passphrase_key_different_info_differs() {
let key_a = derive_passphrase_key(b"same-passphrase", b"info-a").unwrap();
let key_b = derive_passphrase_key(b"same-passphrase", b"info-b").unwrap();
assert_ne!(
key_a, key_b,
"Different info labels must produce different keys"
);
}
#[test]
fn test_derive_passphrase_key_different_passphrase_differs() {
let key_a = derive_passphrase_key(b"passphrase-one", b"same-info").unwrap();
let key_b = derive_passphrase_key(b"passphrase-two", b"same-info").unwrap();
assert_ne!(
key_a, key_b,
"Different passphrases must produce different keys"
);
}
#[test]
fn test_unlock_code_from_string_deterministic() {
let uc1 = UnlockCode::from_string("my-unlock-phrase").unwrap();
let uc2 = UnlockCode::from_string("my-unlock-phrase").unwrap();
assert_eq!(
uc1.0.expose_secret(),
uc2.0.expose_secret(),
"Same input string must produce the same unlock code"
);
}
#[test]
fn test_unlock_code_from_string_different_inputs_differ() {
let uc1 = UnlockCode::from_string("phrase-alpha-long").unwrap();
let uc2 = UnlockCode::from_string("phrase-beta-long").unwrap();
assert_ne!(
uc1.0.expose_secret(),
uc2.0.expose_secret(),
"Different input strings must produce different unlock codes"
);
}
#[test]
fn test_unlock_code_rejects_short_passphrase() {
assert!(
UnlockCode::from_string("short").is_err(),
"Passphrase shorter than MIN_PASSPHRASE_LENGTH should be rejected"
);
}
#[test]
fn test_validate_passphrase_minimum_length() {
assert!(validate_passphrase("12345678").is_ok());
assert!(validate_passphrase("1234567").is_err());
assert!(validate_passphrase("").is_err());
}
fn test_config(
identities: BTreeMap<account::PersonaId, crate::identity::IdentityContext>,
) -> Config {
Config {
protected_key: None,
integrity: Default::default(),
public: public_config::PublicConfig::default(),
private: ProtectedConfig::default(),
key_backend: KeyBackend::Bip32 {
root: ExtendedSigningKey::from_seed(&[7u8; 32]).unwrap(),
seed: SecretString::new("seed".into()),
},
key_info: HashMap::new(),
protection_method: ProtectionMethod::default(),
#[cfg(feature = "openpgp-card")]
token_admin_pin: None,
#[cfg(feature = "openpgp-card")]
token_user_pin: SecretString::new("".into()),
unlock_code: None,
account: account::Account::default(),
identities,
active_persona: None,
runtime_trust_overrides: None,
}
}
fn test_identity(
persona_id: account::PersonaId,
did: &str,
) -> crate::identity::IdentityContext {
use affinidi_tdk::messaging::profiles::{ATMProfile, ATMProfileInner};
use std::sync::Arc;
let document: affinidi_tdk::did_common::Document =
serde_json::from_value(serde_json::json!({ "id": did }))
.expect("minimal DID document deserializes");
crate::identity::IdentityContext {
persona_id,
did: did.to_string(),
document,
profile: Arc::new(ATMProfile {
inner: Arc::new(ATMProfileInner {
did: did.to_string(),
alias: did.to_string(),
mediator: Arc::new(None),
}),
}),
mediator_did: None,
}
}
#[test]
fn agent_name_targets_and_cache_roundtrip() {
use crate::config::account::{PersonaId, PersonaRecord};
use chrono::Utc;
let now = Utc::now();
let mut config = test_config(BTreeMap::new());
let pid = PersonaId::new();
let did = "did:webvh:example.com:alice".to_string();
config.account.personas.insert(
pid,
PersonaRecord {
extra: serde_json::Map::new(),
persona_id: pid,
did: did.clone(),
did_document: None,
key_refs: vec![],
mediator_did: None,
origin_context_id: "openvtc/alice".into(),
created_at: now,
label: None,
},
);
assert_eq!(config.agent_name_refresh_targets(now), vec![did.clone()]);
config.set_cached_agent_name(&did, Some("example.com/@alice".into()), now);
assert!(config.agent_name_refresh_targets(now).is_empty());
assert_eq!(config.agent_name_for(&did), Some("example.com/@alice"));
let did2 = "did:webvh:example.com:bob".to_string();
config.set_cached_agent_name(&did2, None, now);
assert!(config.agent_name_for(&did2).is_none());
assert!(!config.agent_name_refresh_targets(now).contains(&did2));
let later = now + crate::agent_name::AGENT_NAME_TTL;
assert!(config.agent_name_refresh_targets(later).contains(&did));
}
#[test]
fn agent_name_targets_include_the_vta_and_mediator_dids() {
use crate::config::account::{PersonaId, PersonaRecord};
use chrono::Utc;
let now = Utc::now();
let mut config = test_config(BTreeMap::new());
let pid = PersonaId::new();
let mediator = "did:webvh:example.com:mediator".to_string();
config.account.personas.insert(
pid,
PersonaRecord {
extra: serde_json::Map::new(),
persona_id: pid,
did: "did:webvh:example.com:alice".into(),
did_document: None,
key_refs: vec![],
mediator_did: Some(mediator.clone()),
origin_context_id: "openvtc/alice".into(),
created_at: now,
label: None,
},
);
let vta_did = "did:webvh:example.com:vta".to_string();
config.key_backend = KeyBackend::Vta {
vta_url: "https://vta.example".into(),
vta_did: vta_did.clone(),
credential_did: "did:key:z6MkTest".into(),
credential_private_key: SecretString::new("z6Mktest".into()),
mediator_did: Some(mediator.clone()),
credential_bundle: SecretString::new("bundle".into()),
encryption_seed: SecretBox::new(Box::new(vec![0u8; 32])),
};
let targets = config.agent_name_refresh_targets(now);
assert!(targets.contains(&mediator), "mediator swept: {targets:?}");
assert!(targets.contains(&vta_did), "VTA DID swept: {targets:?}");
}
#[test]
fn agent_name_targets_skip_non_did_infrastructure_values() {
use crate::config::account::{PersonaId, PersonaRecord};
use chrono::Utc;
let now = Utc::now();
let mut config = test_config(BTreeMap::new());
let pid = PersonaId::new();
config.account.personas.insert(
pid,
PersonaRecord {
extra: serde_json::Map::new(),
persona_id: pid,
did: "did:webvh:example.com:alice".into(),
did_document: None,
key_refs: vec![],
mediator_did: Some(String::new()),
origin_context_id: "openvtc/alice".into(),
created_at: now,
label: None,
},
);
config.key_backend = KeyBackend::Vta {
vta_url: "https://vta.example".into(),
vta_did: String::new(),
credential_did: "did:key:z6MkTest".into(),
credential_private_key: SecretString::new("z6Mktest".into()),
mediator_did: None,
credential_bundle: SecretString::new("bundle".into()),
encryption_seed: SecretBox::new(Box::new(vec![0u8; 32])),
};
let targets = config.agent_name_refresh_targets(now);
assert!(
!targets.iter().any(|t| t.is_empty()),
"no empty target: {targets:?}"
);
}
#[test]
fn zero_persona_config_has_no_active_identity() {
let config = test_config(BTreeMap::new());
assert!(config.active_identity().is_none());
assert_eq!(config.persona_did(), "");
assert_eq!(config.mediator_did(), "");
}
#[test]
fn setting_mediator_did_without_a_persona_reports_that_it_did_not_apply() {
let mut config = test_config(BTreeMap::new());
assert!(
!config.set_active_mediator_did("did:webvh:example:mediator"),
"no persona to set a mediator on, so the write must report failure"
);
assert_eq!(
config.mediator_did(),
"",
"and must not have invented somewhere to store it"
);
}
#[test]
fn setting_mediator_did_with_a_persona_applies_to_runtime_and_account() {
let pid = account::PersonaId(uuid::Uuid::from_u128(1));
let mut identities = BTreeMap::new();
identities.insert(pid, test_identity(pid, "did:example:persona"));
let mut config = test_config(identities);
config.account.personas.insert(
pid,
account::PersonaRecord {
persona_id: pid,
did: "did:example:persona".to_string(),
did_document: None,
key_refs: Vec::new(),
mediator_did: None,
origin_context_id: "openvtc/test".to_string(),
created_at: chrono::Utc::now(),
label: None,
extra: serde_json::Map::new(),
},
);
assert!(config.set_active_mediator_did("did:webvh:example:mediator"));
assert_eq!(config.mediator_did(), "did:webvh:example:mediator");
assert_eq!(
config
.account
.personas
.get(&pid)
.and_then(|p| p.mediator_did.as_deref()),
Some("did:webvh:example:mediator"),
"the persisted record must move too, or the change is lost on restart"
);
}
#[test]
fn runtime_mediator_override_leaves_the_account_record_alone() {
let pid = account::PersonaId(uuid::Uuid::from_u128(1));
let mut identities = BTreeMap::new();
identities.insert(pid, test_identity(pid, "did:example:persona"));
let mut config = test_config(identities);
config.account.personas.insert(
pid,
account::PersonaRecord {
persona_id: pid,
did: "did:example:persona".to_string(),
did_document: None,
key_refs: Vec::new(),
mediator_did: Some("did:example:persisted-mediator".to_string()),
origin_context_id: "openvtc/test".to_string(),
created_at: chrono::Utc::now(),
label: None,
extra: serde_json::Map::new(),
},
);
assert!(config.set_active_mediator_did_runtime("did:example:runtime-mediator"));
assert_eq!(config.mediator_did(), "did:example:runtime-mediator");
assert_eq!(
config
.account
.personas
.get(&pid)
.and_then(|p| p.mediator_did.as_deref()),
Some("did:example:persisted-mediator"),
);
assert!(
!test_config(BTreeMap::new()).set_active_mediator_did_runtime("did:example:m"),
"no persona, nothing to set"
);
}
#[test]
fn runtime_vta_override_is_never_what_a_save_writes() {
let mut config = test_config(BTreeMap::new());
assert!(!config.override_vta_url_runtime("http://127.0.0.1:1"));
assert_eq!(config.persisted_vta_anchor(), None);
config.key_backend = KeyBackend::Vta {
credential_bundle: SecretString::new("".into()),
credential_did: String::new(),
credential_private_key: SecretString::new("".into()),
vta_did: "did:example:vta".to_string(),
vta_url: "https://vta.example".to_string(),
mediator_did: None,
encryption_seed: SecretBox::new(Box::new(vec![0u8; 32])),
};
assert!(config.override_vta_url_runtime("http://127.0.0.1:1"));
assert!(config.override_vta_url_runtime("http://127.0.0.1:2"));
assert!(config.override_vta_did_runtime("did:example:other"));
let KeyBackend::Vta {
vta_url, vta_did, ..
} = &config.key_backend
else {
unreachable!()
};
assert_eq!(
(vta_url.as_str(), vta_did.as_str()),
("http://127.0.0.1:2", "did:example:other")
);
assert_eq!(
config.persisted_vta_anchor(),
Some(("https://vta.example", "did:example:vta"))
);
let secured = secured_config::SecuredConfig::from(&config);
assert_eq!(secured.vta_url.as_deref(), Some("https://vta.example"));
assert_eq!(secured.vta_did.as_deref(), Some("did:example:vta"));
let KeyBackend::Vta {
vta_url, vta_did, ..
} = config.key_backend_for_save().expect("clone")
else {
unreachable!()
};
assert_eq!(
(vta_url.as_str(), vta_did.as_str()),
("https://vta.example", "did:example:vta")
);
}
#[test]
fn active_identity_is_deterministic_regardless_of_insertion_order() {
let pid_low = account::PersonaId(uuid::Uuid::from_u128(1));
let pid_high = account::PersonaId(uuid::Uuid::from_u128(2));
assert!(pid_low < pid_high);
let mut forward = BTreeMap::new();
forward.insert(pid_low, test_identity(pid_low, "did:example:low"));
forward.insert(pid_high, test_identity(pid_high, "did:example:high"));
let config_forward = test_config(forward);
let mut reverse = BTreeMap::new();
reverse.insert(pid_high, test_identity(pid_high, "did:example:high"));
reverse.insert(pid_low, test_identity(pid_low, "did:example:low"));
let config_reverse = test_config(reverse);
let active_forward = config_forward.active_identity().expect("identity resolved");
assert_eq!(active_forward.persona_id, pid_low);
assert_eq!(active_forward.did, "did:example:low");
let active_reverse = config_reverse.active_identity().expect("identity resolved");
assert_eq!(active_reverse.persona_id, active_forward.persona_id);
assert_eq!(active_reverse.did, active_forward.did);
assert_eq!(config_forward.persona_did(), config_reverse.persona_did());
}
}
#[cfg(test)]
mod peer_transport_tests {
use super::*;
use crate::didcomm::MessagingTransport;
#[test]
fn tsp_is_preferred_when_both_are_advertised() {
let both = PeerTransports {
tsp_mediator: Some("did:web:tsp-mediator".into()),
didcomm_mediator: Some("did:web:didcomm-mediator".into()),
};
assert_eq!(both.preferred(), Some(MessagingTransport::Tsp));
assert!(both.any());
}
#[test]
fn didcomm_is_used_when_tsp_is_absent() {
let didcomm_only = PeerTransports {
tsp_mediator: None,
didcomm_mediator: Some("did:web:didcomm-mediator".into()),
};
assert_eq!(didcomm_only.preferred(), Some(MessagingTransport::DidComm));
assert!(didcomm_only.any());
}
#[test]
fn a_peer_advertising_nothing_is_not_joinable() {
let none = PeerTransports::default();
assert_eq!(none.preferred(), None);
assert!(!none.any());
}
#[test]
fn tsp_only_is_joinable_because_advertisement_is_all_we_can_see() {
let tsp_only = PeerTransports {
tsp_mediator: Some("did:web:tsp-mediator".into()),
didcomm_mediator: None,
};
assert_eq!(tsp_only.preferred(), Some(MessagingTransport::Tsp));
assert!(tsp_only.any());
}
}