use affinidi_tdk::{
messaging::protocols::mediator::acls::AccessListModeType, secrets_resolver::secrets::Secret,
};
use serde_derive::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt;
use tracing::warn;
use crate::didcomm::did_document::{TransportFlags, build_did_document, validate_public_url};
use super::{
Configs,
loaders::{environment::*, load},
};
async fn load_profile_from_secret_store() -> Result<Option<String>, String> {
#[cfg(feature = "secrets")]
{
use super::secret_store;
let cfg = secret_store::secrets_config_from_env();
if !secret_store::backend_selected(&cfg) {
return Ok(None);
}
secret_store::read_profile(&cfg, &secret_store::data_dir()).await
}
#[cfg(not(feature = "secrets"))]
Ok(None)
}
async fn load_profile_from_vta() -> Result<Option<String>, String> {
#[cfg(feature = "vta")]
{
super::vta::startup_profile_json().await
}
#[cfg(not(feature = "vta"))]
{
Ok(None)
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AuditLogFormat {
Text,
#[default]
Json,
}
impl fmt::Display for AuditLogFormat {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Text => write!(f, "text"),
Self::Json => write!(f, "json"),
}
}
}
impl std::str::FromStr for AuditLogFormat {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"text" => Ok(Self::Text),
"json" => Ok(Self::Json),
_ => Err(format!("Invalid audit log format: {s}")),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct AuditConfig {
pub log_format: AuditLogFormat,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProfileConfig {
pub did: String,
pub alias: String,
pub secrets: Vec<Secret>,
}
#[derive(Debug, Clone, Default)]
pub struct AdminConfig {
pub admin_dids: Vec<String>,
pub admin_authorities: HashMap<String, Vec<String>>,
pub audit_config: AuditConfig,
}
pub fn parse_admin_authorities(
raw: Option<&str>,
admin_dids: &[String],
) -> Result<HashMap<String, Vec<String>>, String> {
let Some(raw) = raw.map(str::trim).filter(|raw| !raw.is_empty()) else {
return Ok(HashMap::new());
};
let admin_authorities: HashMap<String, Vec<String>> = serde_json::from_str(raw).map_err(|e| {
format!(
"ADMIN_AUTHORITIES must be a JSON object mapping an admin DID to a list of authority DIDs: {e}"
)
})?;
if let Some(unknown) = admin_authorities
.keys()
.find(|admin| !admin_dids.contains(admin))
{
return Err(format!(
"ADMIN_AUTHORITIES names {unknown}, which is not in ADMIN_DIDS"
));
}
Ok(admin_authorities)
}
#[derive(Debug, Clone)]
pub struct DidDocumentRetryConfig {
pub max_attempts: u32,
pub initial_delay_secs: u64,
pub max_delay_secs: u64,
}
impl Default for DidDocumentRetryConfig {
fn default() -> Self {
Self {
max_attempts: 10,
initial_delay_secs: 2,
max_delay_secs: 30,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct DidcommConfig {
pub is_enabled: bool,
pub transport_flags: TransportFlags,
pub acl_mode: AccessListModeType,
pub profile_config: ProfileConfig,
pub mediator_did: String,
pub did_document: String,
pub admin_config: AdminConfig,
pub retry_config: DidDocumentRetryConfig,
}
impl DidcommConfig {
pub fn disabled() -> Self {
Self {
is_enabled: false,
transport_flags: TransportFlags {
rest: true,
didcomm: false,
tsp: false,
},
..Default::default()
}
}
}
pub fn parse_profile_from_secrets_str(
did_and_secrets_as_str: &str,
) -> Result<ProfileConfig, Box<dyn std::error::Error + Send + Sync>> {
let profile_config: ProfileConfig = serde_json::from_str(did_and_secrets_as_str)?;
Ok(profile_config)
}
fn env_flag(name: &str, default: bool) -> Result<bool, String> {
match std::env::var(name) {
Err(_) => Ok(default),
Ok(raw) => match raw.trim().to_ascii_lowercase().as_str() {
"" => Ok(default),
"true" => Ok(true),
"false" => Ok(false),
other => Err(format!("{name} must be 'true' or 'false' (got '{other}')")),
},
}
}
pub fn transport_flags_from_env() -> Result<TransportFlags, String> {
let flags = TransportFlags {
rest: env_flag("ENABLE_REST", true)?,
didcomm: env_flag("ENABLE_DIDCOMM", true)?,
tsp: env_flag("ENABLE_TSP", false)?,
};
flags.validate()?;
Ok(flags)
}
#[async_trait::async_trait]
impl Configs for DidcommConfig {
async fn load() -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
let transport_flags = transport_flags_from_env()?;
if !transport_flags.didcomm {
warn!(
"DIDComm is disabled; no DID document will be published. \
Consumers must reach this registry by URL."
);
return Ok(DidcommConfig {
is_enabled: false,
transport_flags,
..Default::default()
});
}
let acl_mode_raw = env_or("ACL_MODE", "ExplicitDeny");
let acl_mode = if acl_mode_raw == "ExplicitAllow" {
AccessListModeType::ExplicitAllow
} else {
AccessListModeType::ExplicitDeny
};
let admin_dids_str = optional_env("ADMIN_DIDS").unwrap_or_else(|| {
warn!("Missing environment variable: ADMIN_DIDS. The admin list is empty");
String::new()
});
let admin_dids: Vec<String> = admin_dids_str
.split(',')
.map(|e| e.trim().to_string())
.collect();
let log_format = env_or("AUDIT_LOG_FORMAT", "json")
.parse::<AuditLogFormat>()
.unwrap_or(AuditLogFormat::Json);
let admin_authorities =
parse_admin_authorities(optional_env("ADMIN_AUTHORITIES").as_deref(), &admin_dids)?;
let admin_config = AdminConfig {
admin_dids,
admin_authorities,
audit_config: AuditConfig { log_format },
};
let mediator_did = required_env("MEDIATOR_DID")?;
let profile_configs_str = match load_profile_from_vta().await? {
Some(bundle) => bundle,
None => match load_profile_from_secret_store().await? {
Some(bundle) => bundle,
None => {
let profile_configs_uri = required_env("PROFILE_CONFIG")?;
load(&profile_configs_uri).await?
}
},
};
let profile_config = parse_profile_from_secrets_str(&profile_configs_str)?;
let public_url = optional_env("TR_PUBLIC_URL")
.map(|u| u.trim().to_string())
.filter(|u| !u.is_empty());
if let Some(url) = public_url.as_deref() {
validate_public_url(url)?;
}
if transport_flags.rest && public_url.is_none() {
warn!(
"ENABLE_REST=true but TR_PUBLIC_URL is unset: REST is served but not \
advertised in the DID document. Set TR_PUBLIC_URL to make it discoverable."
);
}
let did_document = if let Some(doc) = optional_env("DID_DOCUMENT") {
load(&doc).await?
} else {
build_did_document(
&profile_config,
&mediator_did,
public_url.as_deref(),
transport_flags,
)
};
let retry_config = DidDocumentRetryConfig {
max_attempts: env_or("DID_CHECK_MAX_ATTEMPTS", "10").parse().unwrap_or(10),
initial_delay_secs: env_or("DID_CHECK_INITIAL_DELAY_SECS", "2")
.parse()
.unwrap_or(2),
max_delay_secs: env_or("DID_CHECK_MAX_DELAY_SECS", "20")
.parse()
.unwrap_or(20),
};
Ok(DidcommConfig {
is_enabled: true,
transport_flags,
acl_mode,
mediator_did,
profile_config,
did_document,
admin_config,
retry_config,
})
}
}
#[cfg(test)]
mod tests {
use super::parse_admin_authorities;
const ADMIN: &str = "did:example:admin";
fn admins() -> Vec<String> {
vec![ADMIN.to_string()]
}
#[test]
fn absent_or_empty_admin_authorities_grant_nothing_extra() {
assert!(parse_admin_authorities(None, &admins()).unwrap().is_empty());
assert!(
parse_admin_authorities(Some(" "), &admins())
.unwrap()
.is_empty()
);
}
#[test]
fn admin_authorities_map_an_admin_to_its_extra_authorities() {
let parsed = parse_admin_authorities(
Some(r#"{"did:example:admin": ["did:example:a", "did:example:b"]}"#),
&admins(),
)
.unwrap();
assert_eq!(parsed[ADMIN], vec!["did:example:a", "did:example:b"]);
}
#[test]
fn malformed_admin_authorities_are_an_error() {
assert!(
parse_admin_authorities(Some("did:example:admin=did:example:a"), &admins()).is_err()
);
}
#[test]
fn admin_authorities_for_a_non_admin_are_an_error() {
assert!(
parse_admin_authorities(
Some(r#"{"did:example:stranger": ["did:example:a"]}"#),
&admins()
)
.is_err()
);
}
}