use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use vti_common::error::AppError;
pub use vti_common::config::{
AuditConfig, AuthConfig, LogConfig, LogFormat, MessagingConfig, StoreConfig, VaultConfig,
};
pub use vti_secrets::{SecretBackend, SecretsConfig};
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct PolicyConfig {
#[serde(default)]
pub enforcement: bool,
#[serde(default)]
pub approver_sets: std::collections::HashMap<String, Vec<String>>,
#[serde(default)]
pub require_payload_schema: bool,
#[serde(default)]
pub require_consent: Vec<RequireConsentRule>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RequireConsentRule {
pub task_type: String,
pub approver_set: String,
#[serde(default)]
pub min_approvals: Option<u32>,
#[serde(default)]
pub exclude_requester: Option<bool>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AppConfig {
pub vta_did: Option<String>,
#[serde(alias = "community_name")]
pub vta_name: Option<String>,
pub public_url: Option<String>,
#[serde(default)]
pub resolver_url: Option<String>,
#[serde(default = "default_server_config")]
pub server: ServerConfig,
#[serde(default)]
pub log: LogConfig,
#[serde(default = "default_store_config")]
pub store: StoreConfig,
pub messaging: Option<MessagingConfig>,
#[serde(default)]
pub services: ServicesConfig,
#[serde(default)]
pub auth: AuthConfig,
#[serde(default)]
pub audit: AuditConfig,
#[serde(default)]
pub vault: VaultConfig,
#[serde(default)]
pub policy: PolicyConfig,
#[serde(default)]
pub secrets: SecretsConfig,
#[serde(default)]
pub trusted_presentation_verifiers: Vec<String>,
#[serde(default)]
pub credential_holder_did: Option<String>,
#[cfg(feature = "tee")]
#[serde(default)]
pub tee: TeeConfig,
#[serde(default)]
pub hardened: HardenedConfig,
#[serde(skip)]
pub config_path: PathBuf,
#[serde(skip)]
pub unknown_keys: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ServicesConfig {
#[serde(default = "default_true")]
pub rest: bool,
#[serde(default = "default_true")]
pub didcomm: bool,
#[serde(default)]
pub webauthn: bool,
#[serde(default)]
pub tsp: bool,
}
fn default_true() -> bool {
true
}
impl Default for ServicesConfig {
fn default() -> Self {
Self {
rest: true,
didcomm: true,
webauthn: false,
tsp: false,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ServerConfig {
#[serde(default = "default_host")]
pub host: String,
#[serde(default = "default_port")]
pub port: u16,
#[serde(default)]
pub cors_origins: Vec<String>,
#[serde(default)]
pub trust_xff: bool,
}
fn default_host() -> String {
"0.0.0.0".to_string()
}
fn default_port() -> u16 {
8100
}
fn default_server_config() -> ServerConfig {
ServerConfig::default()
}
fn default_store_config() -> StoreConfig {
StoreConfig {
data_dir: PathBuf::from("data/vta"),
}
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
host: default_host(),
port: default_port(),
cors_origins: Vec::new(),
trust_xff: false,
}
}
}
#[cfg(feature = "tee")]
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct TeeConfig {
#[serde(default)]
pub mode: TeeMode,
#[serde(default)]
pub embed_in_did: bool,
#[serde(default = "default_attestation_cache_ttl")]
pub attestation_cache_ttl: u64,
#[serde(default)]
pub kms: Option<TeeKmsConfig>,
#[serde(default = "default_storage_key_salt")]
pub storage_key_salt: String,
#[serde(default)]
pub allowed_did_methods: Option<Vec<String>>,
}
#[cfg(feature = "tee")]
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct TeeKmsConfig {
pub region: String,
pub key_arn: String,
#[serde(default)]
pub vta_did_template: Option<String>,
#[serde(default = "default_admin_context_id")]
pub admin_context_id: String,
#[serde(default)]
pub admin_did: Option<String>,
#[serde(default)]
pub allow_unattested_fallback: bool,
#[serde(default)]
pub allow_fingerprint_init: bool,
#[serde(default)]
pub allow_kms_reinit: bool,
#[serde(default)]
pub allow_anchor_init: bool,
#[serde(default)]
pub anchor: Option<TeeAnchorConfig>,
#[serde(default)]
pub allow_unanchored: bool,
}
#[cfg(feature = "tee")]
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct TeeAnchorConfig {
pub table_name: String,
#[serde(default)]
pub writer_credential_ciphertext: Option<String>,
}
#[cfg(feature = "tee")]
fn default_admin_context_id() -> String {
"default".to_string()
}
#[cfg(feature = "tee")]
fn default_attestation_cache_ttl() -> u64 {
300
}
#[cfg(feature = "tee")]
fn default_storage_key_salt() -> String {
"vta-tee-storage-v1".to_string()
}
fn default_hardened_storage_key_salt() -> String {
"vta-storage-v1".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HardenedConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_hardened_storage_key_salt")]
pub storage_key_salt: String,
}
impl Default for HardenedConfig {
fn default() -> Self {
Self {
enabled: false,
storage_key_salt: default_hardened_storage_key_salt(),
}
}
}
#[cfg(feature = "tee")]
impl Default for TeeConfig {
fn default() -> Self {
Self {
mode: TeeMode::default(),
embed_in_did: false,
attestation_cache_ttl: default_attestation_cache_ttl(),
kms: None,
storage_key_salt: default_storage_key_salt(),
allowed_did_methods: None,
}
}
}
#[cfg(feature = "tee")]
#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum TeeMode {
Required,
#[default]
Optional,
Simulated,
}
impl AppConfig {
pub fn load(config_path: Option<PathBuf>) -> Result<Self, AppError> {
let path = config_path
.or_else(|| std::env::var("VTA_CONFIG_PATH").ok().map(PathBuf::from))
.unwrap_or_else(|| PathBuf::from("config.toml"));
if !path.exists() {
return Err(AppError::Config(format!(
"configuration file not found: {}",
path.display()
)));
}
let contents = std::fs::read_to_string(&path).map_err(AppError::Io)?;
let de = toml::Deserializer::parse(&contents)
.map_err(|e| AppError::Config(format!("failed to parse {}: {e}", path.display())))?;
let mut unknown_keys: Vec<String> = Vec::new();
let mut config: AppConfig = serde_ignored::deserialize(de, |key_path| {
unknown_keys.push(key_path.to_string());
})
.map_err(|e| AppError::Config(format!("failed to parse {}: {e}", path.display())))?;
config.config_path = path.clone();
config.unknown_keys = unknown_keys;
#[cfg(feature = "tee")]
let kms_locked = config.tee.kms.is_some();
#[cfg(not(feature = "tee"))]
let kms_locked = false;
if kms_locked {
if let Ok(level) = std::env::var("VTA_LOG_LEVEL") {
config.log.level = level;
}
if let Ok(format) = std::env::var("VTA_LOG_FORMAT") {
config.log.format = match format.to_lowercase().as_str() {
"json" => LogFormat::Json,
"text" => LogFormat::Text,
other => {
return Err(AppError::Config(format!(
"invalid VTA_LOG_FORMAT '{other}', expected 'text' or 'json'"
)));
}
};
}
let blocked_vars = [
"VTA_DID",
"VTA_SERVER_HOST",
"VTA_SERVER_PORT",
"VTA_PUBLIC_URL",
"VTA_STORE_DATA_DIR",
"VTA_MESSAGING_MEDIATOR_URL",
"VTA_MESSAGING_MEDIATOR_DID",
"VTA_SECRETS_SEED",
"VTA_SECRETS_AWS_SECRET_NAME",
"VTA_SECRETS_AWS_REGION",
"VTA_SECRETS_GCP_PROJECT",
"VTA_SECRETS_GCP_SECRET_NAME",
"VTA_SECRETS_AZURE_VAULT_URL",
"VTA_SECRETS_AZURE_SECRET_NAME",
"VTA_SECRETS_KEYRING_SERVICE",
"VTA_SECRETS_K8S_SECRET_NAME",
"VTA_SECRETS_K8S_NAMESPACE",
"VTA_SECRETS_K8S_SECRET_KEY",
"VTA_AUTH_ACCESS_EXPIRY",
"VTA_AUTH_REFRESH_EXPIRY",
"VTA_AUTH_CHALLENGE_TTL",
"VTA_AUTH_SESSION_CLEANUP_INTERVAL",
"VTA_AUTH_JWT_SIGNING_KEY",
"VTA_TEE_MODE",
"VTA_TEE_EMBED_IN_DID",
"VTA_TEE_ATTESTATION_CACHE_TTL",
];
for var in &blocked_vars {
if std::env::var(var).is_ok() {
tracing::warn!(
"SECURITY: {var} env var ignored — config is locked when KMS bootstrap is active"
);
}
}
} else {
Self::apply_env_overrides(&mut config)?;
}
Ok(config)
}
fn apply_env_overrides(config: &mut AppConfig) -> Result<(), AppError> {
if let Ok(vta_did) = std::env::var("VTA_DID") {
config.vta_did = Some(vta_did);
}
if let Ok(host) = std::env::var("VTA_SERVER_HOST") {
config.server.host = host;
}
if let Ok(port) = std::env::var("VTA_SERVER_PORT") {
config.server.port = port
.parse()
.map_err(|e| AppError::Config(format!("invalid VTA_SERVER_PORT: {e}")))?;
}
if let Ok(level) = std::env::var("VTA_LOG_LEVEL") {
config.log.level = level;
}
if let Ok(format) = std::env::var("VTA_LOG_FORMAT") {
config.log.format = match format.to_lowercase().as_str() {
"json" => LogFormat::Json,
"text" => LogFormat::Text,
other => {
return Err(AppError::Config(format!(
"invalid VTA_LOG_FORMAT '{other}', expected 'text' or 'json'"
)));
}
};
}
if let Ok(public_url) = std::env::var("VTA_PUBLIC_URL") {
config.public_url = Some(public_url);
}
if let Ok(data_dir) = std::env::var("VTA_STORE_DATA_DIR") {
config.store.data_dir = PathBuf::from(data_dir);
}
match (
std::env::var("VTA_MESSAGING_MEDIATOR_URL"),
std::env::var("VTA_MESSAGING_MEDIATOR_DID"),
) {
(Ok(url), Ok(did)) => {
config.messaging = Some(MessagingConfig {
mediator_url: url,
mediator_did: did,
mediator_host: None,
setup_acl: false,
drain_inbox_on_start: false,
});
}
(Ok(url), Err(_)) => {
let messaging = config.messaging.get_or_insert(MessagingConfig {
mediator_url: String::new(),
mediator_did: String::new(),
mediator_host: None,
setup_acl: false,
drain_inbox_on_start: false,
});
messaging.mediator_url = url;
}
(Err(_), Ok(did)) => {
let messaging = config.messaging.get_or_insert(MessagingConfig {
mediator_url: String::new(),
mediator_did: String::new(),
mediator_host: None,
setup_acl: false,
drain_inbox_on_start: false,
});
messaging.mediator_did = did;
}
(Err(_), Err(_)) => {}
}
if let Ok(seed) = std::env::var("VTA_SECRETS_SEED") {
config.secrets.seed = Some(seed);
}
if let Ok(name) = std::env::var("VTA_SECRETS_AWS_SECRET_NAME") {
config.secrets.aws_secret_name = Some(name);
}
if let Ok(region) = std::env::var("VTA_SECRETS_AWS_REGION") {
config.secrets.aws_region = Some(region);
}
if let Ok(project) = std::env::var("VTA_SECRETS_GCP_PROJECT") {
config.secrets.gcp_project = Some(project);
}
if let Ok(name) = std::env::var("VTA_SECRETS_GCP_SECRET_NAME") {
config.secrets.gcp_secret_name = Some(name);
}
if let Ok(url) = std::env::var("VTA_SECRETS_AZURE_VAULT_URL") {
config.secrets.azure_vault_url = Some(url);
}
if let Ok(name) = std::env::var("VTA_SECRETS_AZURE_SECRET_NAME") {
config.secrets.azure_secret_name = Some(name);
}
if let Ok(service) = std::env::var("VTA_SECRETS_KEYRING_SERVICE") {
config.secrets.keyring_service = service;
}
if let Ok(addr) =
std::env::var("VAULT_ADDR").or_else(|_| std::env::var("VTA_SECRETS_VAULT_ADDR"))
{
config.secrets.vault_addr = Some(addr);
}
if let Ok(ns) = std::env::var("VAULT_NAMESPACE")
.or_else(|_| std::env::var("VTA_SECRETS_VAULT_NAMESPACE"))
{
config.secrets.vault_namespace = Some(ns);
}
if let Ok(path) = std::env::var("VTA_SECRETS_VAULT_SECRET_PATH") {
config.secrets.vault_secret_path = Some(path);
}
if let Ok(key) = std::env::var("VTA_SECRETS_VAULT_SECRET_KEY") {
config.secrets.vault_secret_key = key;
}
if let Ok(mount) = std::env::var("VTA_SECRETS_VAULT_KV_MOUNT") {
config.secrets.vault_kv_mount = mount;
}
if let Ok(method) = std::env::var("VTA_SECRETS_VAULT_AUTH_METHOD") {
config.secrets.vault_auth_method = method;
}
if let Ok(role) = std::env::var("VTA_SECRETS_VAULT_K8S_ROLE") {
config.secrets.vault_k8s_role = Some(role);
}
if let Ok(mount) = std::env::var("VTA_SECRETS_VAULT_K8S_MOUNT") {
config.secrets.vault_k8s_mount = mount;
}
if let Ok(jwt) = std::env::var("VTA_SECRETS_VAULT_K8S_JWT_PATH") {
config.secrets.vault_k8s_jwt_path = jwt;
}
if let Ok(token) = std::env::var("VAULT_TOKEN") {
config.secrets.vault_token = Some(token);
}
if let Ok(rid) = std::env::var("VTA_SECRETS_VAULT_APPROLE_ROLE_ID") {
config.secrets.vault_approle_role_id = Some(rid);
}
if let Ok(sid) = std::env::var("VTA_SECRETS_VAULT_APPROLE_SECRET_ID") {
config.secrets.vault_approle_secret_id = Some(sid);
}
if let Ok(mount) = std::env::var("VTA_SECRETS_VAULT_APPROLE_MOUNT") {
config.secrets.vault_approle_mount = mount;
}
if let Ok(skip) = std::env::var("VAULT_SKIP_VERIFY")
.or_else(|_| std::env::var("VTA_SECRETS_VAULT_SKIP_VERIFY"))
{
config.secrets.vault_skip_verify =
matches!(skip.to_ascii_lowercase().as_str(), "1" | "true" | "yes");
}
if let Ok(name) = std::env::var("VTA_SECRETS_K8S_SECRET_NAME") {
config.secrets.k8s_secret_name = Some(name);
}
if let Ok(ns) = std::env::var("VTA_SECRETS_K8S_NAMESPACE") {
config.secrets.k8s_namespace = Some(ns);
}
if let Ok(key) = std::env::var("VTA_SECRETS_K8S_SECRET_KEY") {
config.secrets.k8s_secret_key = key;
}
if let Ok(expiry) = std::env::var("VTA_AUTH_ACCESS_EXPIRY") {
config.auth.access_token_expiry = expiry
.parse()
.map_err(|e| AppError::Config(format!("invalid VTA_AUTH_ACCESS_EXPIRY: {e}")))?;
}
if let Ok(expiry) = std::env::var("VTA_AUTH_REFRESH_EXPIRY") {
config.auth.refresh_token_expiry = expiry
.parse()
.map_err(|e| AppError::Config(format!("invalid VTA_AUTH_REFRESH_EXPIRY: {e}")))?;
}
if let Ok(ttl) = std::env::var("VTA_AUTH_CHALLENGE_TTL") {
config.auth.challenge_ttl = ttl
.parse()
.map_err(|e| AppError::Config(format!("invalid VTA_AUTH_CHALLENGE_TTL: {e}")))?;
}
if let Ok(interval) = std::env::var("VTA_AUTH_SESSION_CLEANUP_INTERVAL") {
config.auth.session_cleanup_interval = interval.parse().map_err(|e| {
AppError::Config(format!("invalid VTA_AUTH_SESSION_CLEANUP_INTERVAL: {e}"))
})?;
}
if let Ok(key) = std::env::var("VTA_AUTH_JWT_SIGNING_KEY") {
config.auth.jwt_signing_key = Some(key);
}
if let Ok(val) = std::env::var("VTA_AUDIT_RETENTION_DAYS")
&& let Ok(days) = val.parse::<u32>()
{
config.audit.retention_days = days;
}
#[cfg(feature = "tee")]
{
if let Ok(mode) = std::env::var("VTA_TEE_MODE") {
config.tee.mode = match mode.to_lowercase().as_str() {
"required" => TeeMode::Required,
"optional" => TeeMode::Optional,
"simulated" => TeeMode::Simulated,
"disabled" => {
tracing::warn!(
"VTA_TEE_MODE=disabled is deprecated — use 'optional' instead"
);
TeeMode::Optional
}
other => {
return Err(AppError::Config(format!(
"invalid VTA_TEE_MODE '{other}', expected 'required', 'optional', or 'simulated'"
)));
}
};
}
if let Ok(val) = std::env::var("VTA_TEE_EMBED_IN_DID") {
config.tee.embed_in_did = val
.parse()
.map_err(|e| AppError::Config(format!("invalid VTA_TEE_EMBED_IN_DID: {e}")))?;
}
if let Ok(val) = std::env::var("VTA_TEE_ATTESTATION_CACHE_TTL") {
config.tee.attestation_cache_ttl = val.parse().map_err(|e| {
AppError::Config(format!("invalid VTA_TEE_ATTESTATION_CACHE_TTL: {e}"))
})?;
}
}
Ok(())
}
pub fn validate(&self) -> Result<(), AppError> {
for key in &self.unknown_keys {
tracing::warn!(
"unknown configuration key `{key}` in {} — ignored. Check for a typo, \
a removed/renamed setting, or a key placed in the wrong [section].",
self.config_path.display()
);
}
let mut errors: Vec<String> = Vec::new();
if self
.public_url
.as_deref()
.is_some_and(|u| u.trim().is_empty())
{
errors.push(
"public_url is set to an empty string — remove the key for a \
serverless VTA, or give it a value (e.g. https://vta.example.com)"
.into(),
);
}
if self
.resolver_url
.as_deref()
.is_some_and(|u| u.trim().is_empty())
{
errors.push(
"resolver_url is set to an empty string — remove the key to resolve \
DIDs locally, or give it a ws:// or wss:// URL"
.into(),
);
}
if self.audit.retention_days == 0 {
errors.push("audit.retention_days must be > 0 (default is 28)".into());
}
if !errors.is_empty() {
return Err(AppError::Config(format!(
"invalid configuration in {}:\n - {}",
self.config_path.display(),
errors.join("\n - ")
)));
}
if self.services.rest && self.public_url.is_none() {
tracing::warn!(
"services.rest = true but public_url is unset — the VTA DID document \
will advertise no reachable REST endpoint"
);
}
Ok(())
}
pub fn save(&self) -> Result<(), AppError> {
let contents = toml::to_string_pretty(self)
.map_err(|e| AppError::Config(format!("failed to serialize config: {e}")))?;
std::fs::write(&self.config_path, contents).map_err(AppError::Io)?;
Ok(())
}
}
#[cfg(test)]
mod validate_tests {
use super::*;
fn cfg(toml_str: &str) -> AppConfig {
toml::from_str::<AppConfig>(toml_str).expect("parse test config")
}
#[test]
fn default_config_validates() {
cfg("")
.validate()
.expect("a fully-defaulted config must validate");
}
#[test]
fn zero_retention_days_is_rejected() {
let err = cfg("[audit]\nretention_days = 0\n")
.validate()
.expect_err("retention_days = 0 must be rejected");
assert!(format!("{err:?}").contains("retention_days"), "{err:?}");
}
#[test]
fn present_but_empty_public_url_is_rejected() {
let err = cfg("public_url = \"\"\n")
.validate()
.expect_err("empty public_url must be rejected");
assert!(format!("{err:?}").contains("public_url"), "{err:?}");
}
#[test]
fn present_but_empty_resolver_url_is_rejected() {
let err = cfg("resolver_url = \" \"\n")
.validate()
.expect_err("whitespace-only resolver_url must be rejected");
assert!(format!("{err:?}").contains("resolver_url"), "{err:?}");
}
#[test]
fn rest_without_public_url_only_warns_does_not_fail() {
cfg("")
.validate()
.expect("rest-without-public_url is advisory, not an error");
}
fn load(contents: &str) -> (AppConfig, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.toml");
std::fs::write(&path, contents).expect("write config");
let config = AppConfig::load(Some(path)).expect("load config");
(config, dir)
}
#[test]
fn unknown_keys_are_collected_not_rejected() {
let (config, _dir) = load(
"vta_naem = \"oops\"\n\
[secrets]\nkyring_service = \"vta-2\"\n",
);
assert!(
config.unknown_keys.iter().any(|k| k == "vta_naem"),
"top-level typo should be flagged: {:?}",
config.unknown_keys
);
assert!(
config
.unknown_keys
.iter()
.any(|k| k == "secrets.kyring_service"),
"nested typo should be flagged with a dotted path: {:?}",
config.unknown_keys
);
config
.validate()
.expect("unknown keys are advisory, not a hard error");
}
#[test]
fn known_keys_and_aliases_are_not_flagged() {
let (config, _dir) = load(
"community_name = \"acme\"\n\
[server]\nport = 9000\n",
);
assert!(
config.unknown_keys.is_empty(),
"known keys + aliases must not be flagged: {:?}",
config.unknown_keys
);
assert_eq!(config.vta_name.as_deref(), Some("acme"));
assert_eq!(config.server.port, 9000);
}
}