use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD as BASE64;
use chrono::Utc;
use didwebvh_rs::url::WebVHURL;
use rand::Rng;
use serde::{Deserialize, Serialize};
use serde_json::json;
use url::Url;
use affinidi_did_resolver_cache_sdk::{DIDCacheClient, config::DIDCacheConfigBuilder};
use crate::config::{
AppConfig, AuditConfig, AuthConfig, LogConfig, MessagingConfig, SecretsConfig, ServerConfig,
ServicesConfig, StoreConfig,
};
use crate::contexts::store_context;
use crate::keys::seed_store::{SeedStore, create_seed_store};
use crate::keys::seeds::{SeedRecord, save_seed_record, set_active_seed_id};
use crate::operations;
use crate::operations::did_webvh::CreateDidWebvhParams;
use crate::store::{KeyspaceHandle, Store};
use crate::webvh_cli::cli_super_admin;
use super::{SetupUi, SilentUi, create_seed_context, generate_mnemonic_silent};
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct WizardInputs {
pub config_path: PathBuf,
#[serde(default)]
pub vta_name: Option<String>,
#[serde(default)]
pub public_url: Option<String>,
pub data_dir: PathBuf,
#[serde(default)]
pub data_dir_exists: ExistingDataDirPolicy,
#[serde(default = "default_services")]
pub services: ServicesConfig,
#[serde(default)]
pub server: ServerConfig,
#[serde(default)]
pub log: LogConfig,
pub secrets: SecretsBackendInput,
#[serde(default)]
pub messaging: MessagingInput,
#[serde(default)]
pub vta_did: VtaDidInput,
#[serde(default)]
pub admin_did: Option<String>,
#[serde(default)]
pub admin_label: Option<String>,
#[serde(default)]
pub resolver_url: Option<String>,
#[serde(default)]
pub audit: AuditConfig,
}
fn default_services() -> ServicesConfig {
ServicesConfig {
rest: true,
didcomm: true,
webauthn: false,
}
}
#[derive(Debug, Deserialize, Serialize, Default, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ExistingDataDirPolicy {
#[default]
Error,
Delete,
}
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Deserialize, Serialize)]
#[serde(tag = "backend", rename_all = "snake_case", deny_unknown_fields)]
pub enum SecretsBackendInput {
Keyring {
#[serde(default = "default_keyring_service")]
service: String,
},
ConfigSeed,
Aws {
#[serde(default)]
region: Option<String>,
secret_name: String,
},
Gcp {
project: String,
secret_name: String,
},
Azure {
vault_url: String,
secret_name: String,
},
Vault {
addr: String,
secret_path: String,
#[serde(default = "default_vault_kv_mount")]
kv_mount: String,
#[serde(default = "default_vault_secret_key")]
secret_key: String,
#[serde(default)]
namespace: Option<String>,
#[serde(default = "default_vault_auth_method")]
auth_method: String,
#[serde(default)]
k8s_role: Option<String>,
#[serde(default = "default_vault_k8s_mount")]
k8s_mount: String,
#[serde(default = "default_vault_k8s_jwt_path")]
k8s_jwt_path: String,
#[serde(default)]
token: Option<String>,
#[serde(default)]
approle_role_id: Option<String>,
#[serde(default)]
approle_secret_id: Option<String>,
#[serde(default = "default_vault_approle_mount")]
approle_mount: String,
#[serde(default)]
skip_verify: bool,
},
Kubernetes {
secret_name: String,
#[serde(default)]
namespace: Option<String>,
#[serde(default = "default_k8s_secret_key")]
secret_key: String,
},
Plaintext,
}
fn default_keyring_service() -> String {
"vta".into()
}
pub(crate) fn default_vault_kv_mount() -> String {
"secret".into()
}
pub(crate) fn default_vault_secret_key() -> String {
"seed".into()
}
pub(crate) fn default_vault_auth_method() -> String {
"kubernetes".into()
}
pub(crate) fn default_vault_k8s_mount() -> String {
"kubernetes".into()
}
pub(crate) fn default_vault_k8s_jwt_path() -> String {
"/var/run/secrets/kubernetes.io/serviceaccount/token".into()
}
pub(crate) fn default_k8s_secret_key() -> String {
"seed".into()
}
pub(crate) fn default_vault_approle_mount() -> String {
"approle".into()
}
#[derive(Debug, Deserialize, Serialize, Default)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum MessagingInput {
#[default]
Skip,
Existing {
did: String,
#[serde(default)]
mediator_host: Option<String>,
},
CreateMediator {
#[serde(default = "default_mediator_context")]
context: String,
url: String,
#[serde(default)]
ws_url: Option<String>,
#[serde(default)]
webvh_url: Option<String>,
#[serde(default)]
mediator_host: Option<String>,
#[serde(default)]
template_vars: HashMap<String, serde_json::Value>,
},
}
fn default_mediator_context() -> String {
"mediator".into()
}
#[derive(Debug, Deserialize, Serialize, Default)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum VtaDidInput {
#[default]
Skip,
Existing { did: String },
CreateDidKey,
CreateWebvh {
url: String,
#[serde(default = "default_true")]
portable: bool,
#[serde(default = "default_pre_rotation_count")]
pre_rotation_count: u32,
#[serde(default)]
did_document_file: Option<PathBuf>,
#[serde(default)]
did_log_file: Option<PathBuf>,
#[serde(default)]
signing_key_id: Option<String>,
#[serde(default)]
ka_key_id: Option<String>,
},
}
#[derive(Default)]
struct AdvancedWebvhOptions {
did_document: Option<serde_json::Value>,
did_log: Option<String>,
signing_key_id: Option<String>,
ka_key_id: Option<String>,
}
fn default_true() -> bool {
true
}
fn default_pre_rotation_count() -> u32 {
1
}
pub async fn run_setup_from_file(file_path: PathBuf) -> Result<(), Box<dyn std::error::Error>> {
let raw = std::fs::read_to_string(&file_path)
.map_err(|e| format!("read setup file {}: {e}", file_path.display()))?;
let inputs: WizardInputs = toml::from_str(&raw)
.map_err(|e| format!("parse setup file {}: {e}", file_path.display()))?;
eprintln!(
"Running non-interactive setup from {} ...",
file_path.display()
);
apply_inputs(inputs, &SilentUi).await
}
pub async fn apply_inputs(
inputs: WizardInputs,
ui: &dyn SetupUi,
) -> Result<(), Box<dyn std::error::Error>> {
if inputs.config_path.exists() {
return Err(format!(
"config file {} already exists — delete it first to re-run setup",
inputs.config_path.display()
)
.into());
}
validate_inputs(&inputs)?;
if inputs.data_dir.exists() {
match inputs.data_dir_exists {
ExistingDataDirPolicy::Error => {
return Err(format!(
"data directory {} already exists — set data_dir_exists = \"delete\" to wipe and re-init",
inputs.data_dir.display()
)
.into());
}
ExistingDataDirPolicy::Delete => {
std::fs::remove_dir_all(&inputs.data_dir)
.map_err(|e| format!("delete {}: {e}", inputs.data_dir.display()))?;
eprintln!(" Deleted existing data directory.");
}
}
}
let store = Store::open(&StoreConfig {
data_dir: inputs.data_dir.clone(),
})?;
let keys_ks = store.keyspace(crate::keyspaces::KEYS)?;
let imported_ks = store.keyspace(crate::keyspaces::IMPORTED_SECRETS)?;
let contexts_ks = store.keyspace(crate::keyspaces::CONTEXTS)?;
let webvh_ks = store.keyspace(crate::keyspaces::WEBVH)?;
let did_templates_ks = store.keyspace(crate::keyspaces::DID_TEMPLATES)?;
let mut vta_ctx = create_seed_context(&contexts_ks, "vta", "Verifiable Trust Agent").await?;
eprintln!(" Created application context: vta");
let mnemonic = generate_mnemonic_silent()?;
ui.confirm_mnemonic(&mnemonic)?;
let seed = mnemonic.to_seed("");
let mut secrets_config = secrets_config_from_input(&inputs.secrets)?;
if matches!(inputs.secrets, SecretsBackendInput::ConfigSeed) {
secrets_config.seed = Some(hex::encode(seed));
} else {
let scratch_config = scratch_config_for_seed_store(
inputs.data_dir.clone(),
secrets_config.clone(),
inputs.config_path.clone(),
);
let seed_store = create_seed_store(&scratch_config).map_err(|e| format!("{e}"))?;
seed_store.set(&seed).await.map_err(|e| format!("{e}"))?;
}
let initial_seed_record = SeedRecord {
id: 0,
seed_hex: None,
seed_enc: None,
created_at: Utc::now(),
retired_at: None,
};
save_seed_record(&keys_ks, &initial_seed_record).await?;
set_active_seed_id(&keys_ks, 0).await?;
let mut jwt_key_bytes = [0u8; 32];
rand::rng().fill_bytes(&mut jwt_key_bytes);
let jwt_signing_key = BASE64.encode(jwt_key_bytes);
let mut wizard_config = scratch_config_for_seed_store(
inputs.data_dir.clone(),
secrets_config.clone(),
inputs.config_path.clone(),
);
let wizard_seed_store: Arc<dyn SeedStore> =
Arc::from(create_seed_store(&wizard_config).map_err(|e| format!("{e}"))?);
let messaging = match &inputs.messaging {
MessagingInput::Skip => None,
MessagingInput::Existing { did, mediator_host } => Some(MessagingConfig {
mediator_url: String::new(),
mediator_did: did.clone(),
mediator_host: mediator_host.clone(),
}),
MessagingInput::CreateMediator {
context,
url,
ws_url,
webvh_url,
mediator_host,
template_vars,
} => {
let _med_ctx =
create_seed_context(&contexts_ks, context, "DIDComm Messaging Mediator").await?;
let mut effective_vars: HashMap<String, serde_json::Value> = template_vars.clone();
effective_vars.insert("URL".into(), json!(url));
let ws_url = match ws_url {
Some(explicit) => explicit.trim().to_string(),
None => super::derive_ws_url(url).ok_or_else(|| {
format!(
"messaging.url '{url}' must start with http:// or https:// so the \
wizard can derive WS_URL (or set messaging.ws_url explicitly)"
)
})?,
};
effective_vars.insert("WS_URL".into(), json!(ws_url));
let did_hosting_url = webvh_url.as_deref().unwrap_or(url);
let mediator_did = create_simple_webvh_did(
context,
context,
did_hosting_url,
true,
1,
None,
false,
Some("didcomm-mediator".into()),
effective_vars,
false,
AdvancedWebvhOptions::default(),
ui,
&keys_ks,
&imported_ks,
&contexts_ks,
&webvh_ks,
&did_templates_ks,
&*wizard_seed_store,
&wizard_config,
)
.await?;
Some(MessagingConfig {
mediator_url: url.clone(),
mediator_did,
mediator_host: mediator_host.clone(),
})
}
};
wizard_config.messaging = messaging.clone();
let vta_did = match &inputs.vta_did {
VtaDidInput::Skip => None,
VtaDidInput::Existing { did } => Some(did.clone()),
VtaDidInput::CreateDidKey => {
let did =
create_vta_did_key("vta", &keys_ks, &contexts_ks, &*wizard_seed_store).await?;
Some(did)
}
VtaDidInput::CreateWebvh {
url,
portable,
pre_rotation_count,
did_document_file,
did_log_file,
signing_key_id,
ka_key_id,
} => {
let did_document = match did_document_file {
Some(path) => {
let raw = std::fs::read_to_string(path).map_err(|e| {
format!("read vta_did.did_document_file {}: {e}", path.display())
})?;
Some(
serde_json::from_str::<serde_json::Value>(&raw).map_err(|e| {
format!("parse vta_did.did_document_file {}: {e}", path.display())
})?,
)
}
None => None,
};
let did_log =
match did_log_file {
Some(path) => Some(std::fs::read_to_string(path).map_err(|e| {
format!("read vta_did.did_log_file {}: {e}", path.display())
})?),
None => None,
};
let advanced = AdvancedWebvhOptions {
did_document,
did_log,
signing_key_id: signing_key_id.clone(),
ka_key_id: ka_key_id.clone(),
};
let services = super::build_vta_additional_services(
&inputs.services,
inputs.public_url.as_deref(),
);
let did = create_simple_webvh_did(
"VTA",
"vta",
url,
*portable,
*pre_rotation_count,
services,
messaging.is_some(),
None,
HashMap::new(),
true,
advanced,
ui,
&keys_ks,
&imported_ks,
&contexts_ks,
&webvh_ks,
&did_templates_ks,
&*wizard_seed_store,
&wizard_config,
)
.await?;
Some(did)
}
};
if let Some(ref did) = vta_did {
vta_ctx.did = Some(did.clone());
vta_ctx.updated_at = Utc::now();
store_context(&contexts_ks, &vta_ctx)
.await
.map_err(|e| format!("{e}"))?;
}
store.persist().await?;
drop(wizard_seed_store);
drop(keys_ks);
drop(imported_ks);
drop(contexts_ks);
drop(webvh_ks);
drop(did_templates_ks);
drop(store);
let config = AppConfig {
trusted_presentation_verifiers: Vec::new(),
credential_holder_did: None,
vta_did: vta_did.clone(),
vta_name: inputs.vta_name.clone(),
public_url: inputs.public_url.clone(),
server: inputs.server.clone(),
log: inputs.log.clone(),
store: StoreConfig {
data_dir: inputs.data_dir.clone(),
},
services: inputs.services.clone(),
messaging: messaging.clone(),
auth: AuthConfig {
jwt_signing_key: Some(jwt_signing_key),
..AuthConfig::default()
},
audit: inputs.audit.clone(),
vault: Default::default(),
secrets: secrets_config,
#[cfg(feature = "tee")]
tee: Default::default(),
resolver_url: inputs.resolver_url.clone(),
config_path: inputs.config_path.clone(),
unknown_keys: Vec::new(),
};
config.save()?;
if let Some(ref admin_did) = inputs.admin_did {
seed_initial_admin(&inputs.data_dir, admin_did, inputs.admin_label.clone()).await?;
}
eprintln!();
eprintln!("\x1b[1;32mSetup complete.\x1b[0m");
eprintln!(" Config: {}", config.config_path.display());
eprintln!(" Data dir: {}", config.store.data_dir.display());
if let Some(ref name) = config.vta_name {
eprintln!(" Name: {name}");
}
if let Some(ref url) = config.public_url {
eprintln!(" URL: {url}");
}
if let Some(ref did) = config.vta_did {
eprintln!(" VTA DID: {did}");
}
if let Some(ref msg) = config.messaging {
eprintln!(" Mediator: {}", msg.mediator_did);
}
if let Some(admin) = &inputs.admin_did {
eprintln!(" Admin: {admin} (sealed)");
} else {
eprintln!();
eprintln!(" ACL is empty. Seed the first admin:");
eprintln!();
eprintln!(" Option A (recommended, reversible) — grant admin access to an");
eprintln!(" existing DID without sealing the VTA. Lets you add more admins");
eprintln!(" later and re-run offline CLI commands:");
eprintln!(" vta import-did --did <did:...> --role admin [--label <name>]");
eprintln!();
eprintln!(" Option B (one-time, seals the VTA) — for immutable-image");
eprintln!(" deployments that should refuse any further offline CLI writes");
eprintln!(" after first admin. Disables `acl`, `keys`, `import-did`,");
eprintln!(" `export-admin` until you run `vta unseal`:");
eprintln!(" vta bootstrap-admin --did <did:...> [--label <name>]");
}
eprintln!();
eprintln!(" Mnemonic was generated and stored in the configured backend.");
eprintln!(" Capture an encrypted backup after the first admin connects:");
eprintln!(" pnm backup export --output vta-backup.vtabak");
eprintln!();
Ok(())
}
fn validate_inputs(inputs: &WizardInputs) -> Result<(), Box<dyn std::error::Error>> {
let mut errors: Vec<String> = Vec::new();
if matches!(inputs.messaging, MessagingInput::CreateMediator { .. }) && !inputs.services.didcomm
{
errors.push("messaging.kind = \"create_mediator\" requires services.didcomm = true".into());
}
if matches!(inputs.messaging, MessagingInput::Existing { .. }) && !inputs.services.didcomm {
errors.push("messaging.kind = \"existing\" requires services.didcomm = true".into());
}
if inputs.services.rest && inputs.public_url.as_deref().is_none_or(str::is_empty) {
errors.push(
"services.rest = true requires `public_url` to be set (e.g. \
`public_url = \"https://vta.example.com\"`); without it the VTA DID \
document has no REST service endpoint to publish"
.into(),
);
}
if let MessagingInput::CreateMediator {
context,
webvh_url,
ws_url,
..
} = &inputs.messaging
{
if context.trim().is_empty() {
errors.push("messaging.context cannot be empty".into());
}
if webvh_url.as_deref().is_some_and(str::is_empty) {
errors.push(
"messaging.webvh_url is set to an empty string; either remove the key to default \
to messaging.url, or provide a hosting URL"
.into(),
);
}
if let Some(ws) = ws_url {
let trimmed = ws.trim();
if trimmed.is_empty() {
errors.push(
"messaging.ws_url is set to an empty string; either remove the key to \
derive it from messaging.url, or provide a ws:// or wss:// endpoint"
.into(),
);
} else if !(trimmed.starts_with("ws://") || trimmed.starts_with("wss://")) {
errors.push(format!(
"messaging.ws_url '{trimmed}' must start with ws:// or wss://"
));
}
}
}
if let VtaDidInput::CreateWebvh {
pre_rotation_count,
did_document_file,
did_log_file,
signing_key_id,
ka_key_id,
..
} = &inputs.vta_did
{
if *pre_rotation_count > 32 {
errors.push(format!(
"vta_did.pre_rotation_count = {pre_rotation_count} is unreasonably large (max 32)"
));
}
let advanced_modes = usize::from(did_document_file.is_some())
+ usize::from(did_log_file.is_some())
+ usize::from(signing_key_id.is_some());
if advanced_modes > 1 {
errors.push(
"vta_did: at most one of `did_document_file`, `did_log_file`, `signing_key_id` \
may be set — they select mutually-exclusive advanced DID-creation modes"
.into(),
);
}
if ka_key_id.is_some() && signing_key_id.is_none() {
errors.push(
"vta_did.ka_key_id requires vta_did.signing_key_id (the key-agreement key pairs \
with an existing signing key)"
.into(),
);
}
}
if let Some(did) = &inputs.admin_did
&& !did.starts_with("did:")
{
errors.push(format!(
"admin_did = {did:?} must be a DID (starts with `did:`)"
));
}
if inputs.resolver_url.as_deref().is_some_and(str::is_empty) {
errors.push(
"resolver_url is set to an empty string; either remove the key or provide a \
WebSocket URL (e.g. `ws://resolver.example.com/did/v1/ws`)"
.into(),
);
}
if inputs.audit.retention_days == 0 {
errors.push("audit.retention_days must be > 0 (default is 28)".into());
}
if errors.is_empty() {
Ok(())
} else {
Err(format!(
"setup file has {} validation error(s):\n - {}",
errors.len(),
errors.join("\n - ")
)
.into())
}
}
fn secrets_config_from_input(
input: &SecretsBackendInput,
) -> Result<SecretsConfig, Box<dyn std::error::Error>> {
Ok(match input {
SecretsBackendInput::Keyring { service } => {
#[cfg(not(feature = "keyring"))]
{
let _ = service;
return Err(
"keyring backend requested but vta-service was built without the `keyring` feature"
.into(),
);
}
#[cfg(feature = "keyring")]
{
SecretsConfig {
keyring_service: service.clone(),
..SecretsConfig::default()
}
}
}
SecretsBackendInput::ConfigSeed => {
#[cfg(not(feature = "config-seed"))]
{
return Err(
"config_seed backend requested but vta-service was built without the `config-seed` feature"
.into(),
);
}
#[cfg(feature = "config-seed")]
{
SecretsConfig {
seed: Some(String::new()), ..Default::default()
}
}
}
SecretsBackendInput::Aws {
region,
secret_name,
} => {
#[cfg(not(feature = "aws-secrets"))]
{
let _ = (region, secret_name);
return Err(
"aws backend requested but vta-service was built without the `aws-secrets` feature"
.into(),
);
}
#[cfg(feature = "aws-secrets")]
{
SecretsConfig {
aws_secret_name: Some(secret_name.clone()),
aws_region: region.clone(),
..Default::default()
}
}
}
SecretsBackendInput::Gcp {
project,
secret_name,
} => {
#[cfg(not(feature = "gcp-secrets"))]
{
let _ = (project, secret_name);
return Err(
"gcp backend requested but vta-service was built without the `gcp-secrets` feature"
.into(),
);
}
#[cfg(feature = "gcp-secrets")]
{
SecretsConfig {
gcp_project: Some(project.clone()),
gcp_secret_name: Some(secret_name.clone()),
..Default::default()
}
}
}
SecretsBackendInput::Azure {
vault_url,
secret_name,
} => {
#[cfg(not(feature = "azure-secrets"))]
{
let _ = (vault_url, secret_name);
return Err(
"azure backend requested but vta-service was built without the `azure-secrets` feature"
.into(),
);
}
#[cfg(feature = "azure-secrets")]
{
SecretsConfig {
azure_vault_url: Some(vault_url.clone()),
azure_secret_name: Some(secret_name.clone()),
..Default::default()
}
}
}
SecretsBackendInput::Vault {
addr,
secret_path,
kv_mount,
secret_key,
namespace,
auth_method,
k8s_role,
k8s_mount,
k8s_jwt_path,
token,
approle_role_id,
approle_secret_id,
approle_mount,
skip_verify,
} => {
#[cfg(not(feature = "vault-secrets"))]
{
let _ = (
addr,
secret_path,
kv_mount,
secret_key,
namespace,
auth_method,
k8s_role,
k8s_mount,
k8s_jwt_path,
token,
approle_role_id,
approle_secret_id,
approle_mount,
skip_verify,
);
return Err(
"vault backend requested but vta-service was built without the `vault-secrets` feature"
.into(),
);
}
#[cfg(feature = "vault-secrets")]
{
SecretsConfig {
vault_addr: Some(addr.clone()),
vault_secret_path: Some(secret_path.clone()),
vault_kv_mount: kv_mount.clone(),
vault_secret_key: secret_key.clone(),
vault_namespace: namespace.clone(),
vault_auth_method: auth_method.clone(),
vault_k8s_role: k8s_role.clone(),
vault_k8s_mount: k8s_mount.clone(),
vault_k8s_jwt_path: k8s_jwt_path.clone(),
vault_token: token.clone(),
vault_approle_role_id: approle_role_id.clone(),
vault_approle_secret_id: approle_secret_id.clone(),
vault_approle_mount: approle_mount.clone(),
vault_skip_verify: *skip_verify,
..SecretsConfig::default()
}
}
}
SecretsBackendInput::Kubernetes {
secret_name,
namespace,
secret_key,
} => {
#[cfg(not(feature = "k8s-secrets"))]
{
let _ = (secret_name, namespace, secret_key);
return Err(
"kubernetes backend requested but vta-service was built without the `k8s-secrets` feature"
.into(),
);
}
#[cfg(feature = "k8s-secrets")]
{
SecretsConfig {
k8s_secret_name: Some(secret_name.clone()),
k8s_namespace: namespace.clone(),
k8s_secret_key: secret_key.clone(),
..SecretsConfig::default()
}
}
}
SecretsBackendInput::Plaintext => {
eprintln!();
eprintln!(
"\x1b[1;33mWARNING: plaintext seed storage selected. NOT for production.\x1b[0m"
);
eprintln!();
SecretsConfig {
allow_plaintext: true,
..SecretsConfig::default()
}
}
})
}
fn scratch_config_for_seed_store(
data_dir: PathBuf,
secrets: SecretsConfig,
config_path: PathBuf,
) -> AppConfig {
AppConfig {
trusted_presentation_verifiers: Vec::new(),
credential_holder_did: None,
vta_did: None,
vta_name: None,
public_url: None,
server: ServerConfig::default(),
log: LogConfig::default(),
store: StoreConfig { data_dir },
services: ServicesConfig::default(),
messaging: None,
auth: AuthConfig::default(),
audit: Default::default(),
vault: Default::default(),
secrets,
#[cfg(feature = "tee")]
tee: Default::default(),
resolver_url: None,
config_path,
unknown_keys: Vec::new(),
}
}
pub(crate) async fn create_vta_did_key(
context_id: &str,
keys_ks: &KeyspaceHandle,
contexts_ks: &KeyspaceHandle,
seed_store: &dyn SeedStore,
) -> Result<String, Box<dyn std::error::Error>> {
use affinidi_tdk::secrets_resolver::secrets::Secret;
use ed25519_dalek_bip32::{DerivationPath, ExtendedSigningKey};
use crate::keys;
use crate::keys::seeds::{get_active_seed_id, load_seed_bytes};
use vta_sdk::keys::KeyType as SdkKeyType;
let active_seed_id = get_active_seed_id(keys_ks).await?;
let seed = load_seed_bytes(keys_ks, seed_store, Some(active_seed_id)).await?;
let ctx = crate::contexts::get_context(contexts_ks, context_id)
.await
.map_err(|e| format!("{e}"))?
.ok_or_else(|| format!("context '{context_id}' not found"))?;
let signing_path = keys::paths::allocate_path(keys_ks, &ctx.base_path)
.await
.map_err(|e| format!("{e}"))?;
let root = ExtendedSigningKey::from_seed(&seed)
.map_err(|e| format!("Failed to create BIP-32 root key: {e}"))?;
let derivation_path: DerivationPath = signing_path
.parse()
.map_err(|e| format!("Invalid derivation path: {e}"))?;
let derived = root
.derive(&derivation_path)
.map_err(|e| format!("Key derivation failed: {e}"))?;
let signing_secret = Secret::generate_ed25519(None, Some(derived.signing_key.as_bytes()));
let signing_pub = signing_secret
.get_public_keymultibase()
.map_err(|e| format!("{e}"))?;
let did = format!("did:key:{signing_pub}");
keys::save_key_record(
keys_ks,
&format!("{did}#key-0"),
&signing_path,
SdkKeyType::Ed25519,
&signing_pub,
"VTA signing key",
Some(context_id),
Some(active_seed_id),
)
.await?;
let st = keys::derive_sealed_transfer_key(
&seed,
&ctx.base_path,
"VTA sealed-transfer producer-assertion key",
keys_ks,
)
.await?;
keys::save_sealed_transfer_key_record(
&did,
&st,
keys_ks,
Some(context_id),
Some(active_seed_id),
)
.await?;
eprintln!(" Created DID: {did}");
Ok(did)
}
#[allow(clippy::too_many_arguments)]
async fn create_simple_webvh_did(
label: &str,
context_id: &str,
url: &str,
portable: bool,
pre_rotation_count: u32,
additional_services: Option<Vec<serde_json::Value>>,
add_mediator_service: bool,
template: Option<String>,
template_vars: HashMap<String, serde_json::Value>,
is_vta_identity: bool,
advanced: AdvancedWebvhOptions,
ui: &dyn SetupUi,
keys_ks: &KeyspaceHandle,
imported_ks: &KeyspaceHandle,
contexts_ks: &KeyspaceHandle,
webvh_ks: &KeyspaceHandle,
did_templates_ks: &KeyspaceHandle,
seed_store: &dyn SeedStore,
config: &AppConfig,
) -> Result<String, Box<dyn std::error::Error>> {
let parsed = Url::parse(url).map_err(|e| format!("invalid DID URL {url:?}: {e}"))?;
let webvh_url =
WebVHURL::parse_url(&parsed).map_err(|e| format!("invalid webvh URL {url:?}: {e}"))?;
let url_str = webvh_url
.get_http_url(None)
.map_err(|e| format!("{e}"))?
.to_string();
let auth = cli_super_admin();
let did_resolver = DIDCacheClient::new(DIDCacheConfigBuilder::default().build()).await?;
let no_bridge: Arc<crate::didcomm_bridge::DIDCommBridge> =
Arc::new(crate::didcomm_bridge::DIDCommBridge::placeholder());
let params = CreateDidWebvhParams {
context_id: context_id.to_string(),
server_id: None,
url: Some(url_str),
path_mode: vta_sdk::protocols::did_management::create::WebvhPathMode::default(),
domain: None,
label: Some(label.to_string()),
portable,
add_mediator_service,
additional_services,
pre_rotation_count,
did_document: advanced.did_document,
did_log: advanced.did_log,
set_primary: true,
signing_key_id: advanced.signing_key_id,
ka_key_id: advanced.ka_key_id,
template,
template_context: None,
template_vars,
is_vta_identity,
};
let deps = operations::did_webvh::CreateDidWebvhDeps {
keys_ks,
imported_ks,
contexts_ks,
webvh_ks,
did_templates_ks,
seed_store,
config,
did_resolver: &did_resolver,
didcomm_bridge: &no_bridge,
};
let result = operations::did_webvh::create_did_webvh(&deps, &auth, params, "setup")
.await
.map_err(|e| format!("{e}"))?;
let final_did = result.did.clone();
eprintln!(" Created DID: {final_did}");
if let Some(ref log_entry) = result.log_entry {
let canonical = config
.store
.data_dir
.join("did-logs")
.join(format!("{label}-did.jsonl"));
if let Some(log_path) = ui.did_log_path(label, &canonical) {
if let Some(parent) = log_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&log_path, log_entry)?;
eprintln!(" DID log: {}", log_path.display());
}
}
Ok(final_did)
}
async fn seed_initial_admin(
data_dir: &Path,
did: &str,
label: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
use crate::{acl, seal};
let store = Store::open(&StoreConfig {
data_dir: data_dir.to_path_buf(),
})?;
let acl_ks = store.keyspace(crate::keyspaces::ACL)?;
if let Some(existing) = seal::get_seal(&acl_ks).await? {
return Err(format!(
"VTA is already sealed (by {} on {}); cannot seed admin during setup",
existing.sealed_by, existing.sealed_at
)
.into());
}
let entries = acl::list_acl_entries(&acl_ks).await?;
let existing_super_admins: Vec<_> = entries
.iter()
.filter(|e| e.role == acl::Role::Admin && e.allowed_contexts.is_empty())
.collect();
if !existing_super_admins.is_empty() {
return Err(format!(
"found {} existing super admin(s); refusing to seed another during setup",
existing_super_admins.len()
)
.into());
}
let entry = acl::AclEntry::new(did, acl::Role::Admin, "cli:setup-from-file").with_label(label);
acl::store_acl_entry(&acl_ks, &entry).await?;
let _seal_record = seal::seal(&acl_ks, did).await?;
store.persist().await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(toml_str: &str) -> Result<WizardInputs, Box<dyn std::error::Error>> {
Ok(toml::from_str::<WizardInputs>(toml_str)?)
}
#[test]
fn minimal_keyring_inputs_round_trip() {
let raw = r#"
config_path = "/tmp/vta-test/config.toml"
data_dir = "/tmp/vta-test/data"
[secrets]
backend = "keyring"
"#;
let inputs = parse(raw).expect("minimal inputs should parse");
assert!(matches!(
inputs.secrets,
SecretsBackendInput::Keyring { .. }
));
assert!(matches!(inputs.messaging, MessagingInput::Skip));
assert!(matches!(inputs.vta_did, VtaDidInput::Skip));
assert!(inputs.admin_did.is_none());
}
#[test]
fn plaintext_backend_sets_allow_plaintext() {
let secrets = secrets_config_from_input(&SecretsBackendInput::Plaintext)
.expect("plaintext backend should convert");
assert!(
secrets.allow_plaintext,
"plaintext backend must set allow_plaintext = true"
);
let toml_out = toml::to_string(&secrets).expect("secrets config serializes");
assert!(
toml_out.contains("allow_plaintext = true"),
"generated config must carry the flag, got:\n{toml_out}"
);
}
#[test]
fn unknown_field_rejected() {
let raw = r#"
config_path = "/tmp/vta-test/config.toml"
data_dir = "/tmp/vta-test/data"
bogus_field = "no"
[secrets]
backend = "keyring"
"#;
let err = parse(raw).expect_err("unknown top-level field should fail");
assert!(err.to_string().contains("bogus_field"), "got: {err}");
}
#[test]
fn create_mediator_webvh_url_optional_defaults_to_none() {
let raw = r#"
config_path = "/tmp/vta-test/config.toml"
data_dir = "/tmp/vta-test/data"
public_url = "https://trust.example.com"
[secrets]
backend = "keyring"
[messaging]
kind = "create_mediator"
url = "https://mediator.example.com"
"#;
let inputs = parse(raw).expect("parses");
match &inputs.messaging {
MessagingInput::CreateMediator { webvh_url, .. } => assert!(webvh_url.is_none()),
other => panic!("expected CreateMediator, got {other:?}"),
}
validate_inputs(&inputs).expect("absent webvh_url should validate");
}
#[test]
fn create_mediator_webvh_url_can_be_set() {
let raw = r#"
config_path = "/tmp/vta-test/config.toml"
data_dir = "/tmp/vta-test/data"
public_url = "https://trust.example.com"
[secrets]
backend = "keyring"
[messaging]
kind = "create_mediator"
url = "https://mediator.example.com"
webvh_url = "https://trust.example.com/dids/mediator"
"#;
let inputs = parse(raw).expect("parses");
match &inputs.messaging {
MessagingInput::CreateMediator { webvh_url, .. } => {
assert_eq!(
webvh_url.as_deref(),
Some("https://trust.example.com/dids/mediator")
);
}
other => panic!("expected CreateMediator, got {other:?}"),
}
validate_inputs(&inputs).expect("explicit webvh_url should validate");
}
#[test]
fn create_mediator_empty_webvh_url_rejected() {
let raw = r#"
config_path = "/tmp/vta-test/config.toml"
data_dir = "/tmp/vta-test/data"
public_url = "https://trust.example.com"
[secrets]
backend = "keyring"
[messaging]
kind = "create_mediator"
url = "https://mediator.example.com"
webvh_url = ""
"#;
let inputs = parse(raw).expect("parses");
let err = validate_inputs(&inputs).expect_err("empty webvh_url must be rejected");
assert!(
err.to_string().contains("messaging.webvh_url"),
"got: {err}"
);
}
#[test]
fn create_mediator_ws_url_optional_defaults_to_none() {
let raw = r#"
config_path = "/tmp/vta-test/config.toml"
data_dir = "/tmp/vta-test/data"
public_url = "https://trust.example.com"
[secrets]
backend = "keyring"
[messaging]
kind = "create_mediator"
url = "https://mediator.example.com"
"#;
let inputs = parse(raw).expect("parses");
match &inputs.messaging {
MessagingInput::CreateMediator { ws_url, .. } => assert!(ws_url.is_none()),
other => panic!("expected CreateMediator, got {other:?}"),
}
validate_inputs(&inputs).expect("absent ws_url should validate");
}
#[test]
fn create_mediator_explicit_ws_url_round_trips() {
let raw = r#"
config_path = "/tmp/vta-test/config.toml"
data_dir = "/tmp/vta-test/data"
public_url = "https://trust.example.com"
[secrets]
backend = "keyring"
[messaging]
kind = "create_mediator"
url = "https://mediator.example.com"
ws_url = "wss://ws.example.com/mediator/socket"
"#;
let inputs = parse(raw).expect("parses");
match &inputs.messaging {
MessagingInput::CreateMediator { ws_url, .. } => {
assert_eq!(
ws_url.as_deref(),
Some("wss://ws.example.com/mediator/socket")
);
}
other => panic!("expected CreateMediator, got {other:?}"),
}
validate_inputs(&inputs).expect("explicit ws:// ws_url should validate");
}
#[test]
fn create_mediator_empty_ws_url_rejected() {
let raw = r#"
config_path = "/tmp/vta-test/config.toml"
data_dir = "/tmp/vta-test/data"
public_url = "https://trust.example.com"
[secrets]
backend = "keyring"
[messaging]
kind = "create_mediator"
url = "https://mediator.example.com"
ws_url = ""
"#;
let inputs = parse(raw).expect("parses");
let err = validate_inputs(&inputs).expect_err("empty ws_url must be rejected");
assert!(err.to_string().contains("messaging.ws_url"), "got: {err}");
}
#[test]
fn create_mediator_non_ws_scheme_ws_url_rejected() {
let raw = r#"
config_path = "/tmp/vta-test/config.toml"
data_dir = "/tmp/vta-test/data"
public_url = "https://trust.example.com"
[secrets]
backend = "keyring"
[messaging]
kind = "create_mediator"
url = "https://mediator.example.com"
ws_url = "https://mediator.example.com/ws"
"#;
let inputs = parse(raw).expect("parses");
let err = validate_inputs(&inputs).expect_err("non-ws scheme must be rejected");
assert!(err.to_string().contains("ws:// or wss://"), "got: {err}");
}
#[test]
fn create_mediator_mediator_host_round_trips() {
let raw = r#"
config_path = "/tmp/vta-test/config.toml"
data_dir = "/tmp/vta-test/data"
public_url = "https://trust.example.com"
[secrets]
backend = "keyring"
[messaging]
kind = "create_mediator"
url = "https://mediator.example.com"
mediator_host = "mediator.example.com"
"#;
let inputs = parse(raw).expect("parses");
match &inputs.messaging {
MessagingInput::CreateMediator { mediator_host, .. } => {
assert_eq!(mediator_host.as_deref(), Some("mediator.example.com"));
}
other => panic!("expected CreateMediator, got {other:?}"),
}
validate_inputs(&inputs).expect("mediator_host should validate");
}
#[test]
fn existing_mediator_mediator_host_round_trips() {
let raw = r#"
config_path = "/tmp/vta-test/config.toml"
data_dir = "/tmp/vta-test/data"
public_url = "https://trust.example.com"
[secrets]
backend = "keyring"
[messaging]
kind = "existing"
did = "did:webvh:scid:mediator.example.com:mediator"
mediator_host = "mediator.example.com"
"#;
let inputs = parse(raw).expect("parses");
match &inputs.messaging {
MessagingInput::Existing { mediator_host, .. } => {
assert_eq!(mediator_host.as_deref(), Some("mediator.example.com"));
}
other => panic!("expected Existing, got {other:?}"),
}
validate_inputs(&inputs).expect("Existing+mediator_host should validate");
}
#[test]
fn create_mediator_template_vars_round_trip() {
let raw = r#"
config_path = "/tmp/vta-test/config.toml"
data_dir = "/tmp/vta-test/data"
public_url = "https://trust.example.com"
[secrets]
backend = "keyring"
[messaging]
kind = "create_mediator"
url = "https://mediator.example.com"
[messaging.template_vars]
ROUTING_KEYS = ["did:key:zUpstream"]
ACCEPT = ["didcomm/v2"]
"#;
let inputs = parse(raw).expect("parses");
match &inputs.messaging {
MessagingInput::CreateMediator { template_vars, .. } => {
assert!(template_vars.contains_key("ROUTING_KEYS"));
assert!(template_vars.contains_key("ACCEPT"));
}
other => panic!("expected CreateMediator, got {other:?}"),
}
}
#[test]
fn resolver_url_round_trips() {
let raw = r#"
config_path = "/tmp/vta-test/config.toml"
data_dir = "/tmp/vta-test/data"
public_url = "https://trust.example.com"
resolver_url = "ws://resolver.example.com/did/v1/ws"
[secrets]
backend = "keyring"
"#;
let inputs = parse(raw).expect("parses");
assert_eq!(
inputs.resolver_url.as_deref(),
Some("ws://resolver.example.com/did/v1/ws")
);
validate_inputs(&inputs).expect("resolver_url should validate");
}
#[test]
fn empty_resolver_url_rejected() {
let raw = r#"
config_path = "/tmp/vta-test/config.toml"
data_dir = "/tmp/vta-test/data"
public_url = "https://trust.example.com"
resolver_url = ""
[secrets]
backend = "keyring"
"#;
let inputs = parse(raw).expect("parses");
let err = validate_inputs(&inputs).expect_err("empty resolver_url must be rejected");
assert!(err.to_string().contains("resolver_url"), "got: {err}");
}
#[test]
fn audit_retention_days_round_trips() {
let raw = r#"
config_path = "/tmp/vta-test/config.toml"
data_dir = "/tmp/vta-test/data"
public_url = "https://trust.example.com"
[secrets]
backend = "keyring"
[audit]
retention_days = 365
"#;
let inputs = parse(raw).expect("parses");
assert_eq!(inputs.audit.retention_days, 365);
validate_inputs(&inputs).expect("retention_days = 365 should validate");
}
#[test]
fn audit_retention_days_zero_rejected() {
let raw = r#"
config_path = "/tmp/vta-test/config.toml"
data_dir = "/tmp/vta-test/data"
public_url = "https://trust.example.com"
[secrets]
backend = "keyring"
[audit]
retention_days = 0
"#;
let inputs = parse(raw).expect("parses");
let err = validate_inputs(&inputs).expect_err("retention_days = 0 must be rejected");
assert!(
err.to_string().contains("audit.retention_days"),
"got: {err}"
);
}
#[test]
fn vault_backend_round_trips() {
let raw = r#"
config_path = "/tmp/vta-test/config.toml"
data_dir = "/tmp/vta-test/data"
public_url = "https://trust.example.com"
[secrets]
backend = "vault"
addr = "https://vault.example.com:8200"
secret_path = "vta/master-seed"
auth_method = "kubernetes"
k8s_role = "vta"
"#;
let inputs = parse(raw).expect("parses");
match &inputs.secrets {
SecretsBackendInput::Vault {
addr,
secret_path,
auth_method,
k8s_role,
kv_mount,
secret_key,
..
} => {
assert_eq!(addr, "https://vault.example.com:8200");
assert_eq!(secret_path, "vta/master-seed");
assert_eq!(auth_method, "kubernetes");
assert_eq!(k8s_role.as_deref(), Some("vta"));
assert_eq!(kv_mount, "secret");
assert_eq!(secret_key, "seed");
}
other => panic!("expected Vault, got {other:?}"),
}
validate_inputs(&inputs).expect("vault backend should validate");
}
#[test]
fn create_mediator_without_didcomm_rejected() {
let raw = r#"
config_path = "/tmp/vta-test/config.toml"
data_dir = "/tmp/vta-test/data"
[services]
rest = true
didcomm = false
[secrets]
backend = "keyring"
[messaging]
kind = "create_mediator"
url = "http://localhost:8000"
"#;
let inputs = parse(raw).expect("parses");
let err = validate_inputs(&inputs).expect_err("validation should fail");
assert!(
err.to_string().contains("services.didcomm = true"),
"got: {err}"
);
}
#[test]
fn services_rest_without_public_url_rejected() {
let raw = r#"
config_path = "/tmp/vta-test/config.toml"
data_dir = "/tmp/vta-test/data"
[services]
rest = true
didcomm = false
[secrets]
backend = "keyring"
"#;
let inputs = parse(raw).expect("parses");
let err = validate_inputs(&inputs).expect_err("validation should fail");
let msg = err.to_string();
assert!(
msg.contains("services.rest = true requires `public_url`"),
"got: {err}"
);
}
#[test]
fn services_rest_with_empty_public_url_rejected() {
let raw = r#"
config_path = "/tmp/vta-test/config.toml"
data_dir = "/tmp/vta-test/data"
public_url = ""
[services]
rest = true
didcomm = false
[secrets]
backend = "keyring"
"#;
let inputs = parse(raw).expect("parses");
let err = validate_inputs(&inputs).expect_err("empty public_url must be rejected");
assert!(
err.to_string()
.contains("services.rest = true requires `public_url`"),
"got: {err}"
);
}
#[test]
fn services_rest_with_public_url_passes() {
let raw = r#"
config_path = "/tmp/vta-test/config.toml"
data_dir = "/tmp/vta-test/data"
public_url = "https://vta.example.com"
[services]
rest = true
didcomm = false
[secrets]
backend = "keyring"
"#;
let inputs = parse(raw).expect("parses");
validate_inputs(&inputs).expect("rest + public_url should pass");
}
#[test]
fn services_rest_disabled_does_not_require_public_url() {
let raw = r#"
config_path = "/tmp/vta-test/config.toml"
data_dir = "/tmp/vta-test/data"
[services]
rest = false
didcomm = true
[secrets]
backend = "keyring"
"#;
let inputs = parse(raw).expect("parses");
validate_inputs(&inputs).expect("rest disabled means public_url is optional");
}
#[test]
fn admin_did_validation_rejects_non_did() {
let raw = r#"
config_path = "/tmp/vta-test/config.toml"
data_dir = "/tmp/vta-test/data"
admin_did = "not-a-did"
[secrets]
backend = "keyring"
"#;
let inputs = parse(raw).expect("parses");
let err = validate_inputs(&inputs).expect_err("validation should fail");
assert!(err.to_string().contains("admin_did"), "got: {err}");
}
#[test]
fn shipped_example_parses() {
let raw = include_str!("../../../docs/02-vta/examples/vta-setup.example.toml");
let inputs = parse(raw).expect(
"docs/02-vta/examples/vta-setup.example.toml must be valid against WizardInputs",
);
validate_inputs(&inputs)
.expect("docs/02-vta/examples/vta-setup.example.toml must pass cross-field validation");
}
#[test]
fn full_inputs_parse() {
let raw = r#"
config_path = "/srv/vta/config.toml"
data_dir = "/srv/vta/data"
vta_name = "trust-prod-1"
public_url = "https://trust.example.com"
admin_did = "did:key:z6MkABC"
admin_label = "ops-bootstrap"
[services]
rest = true
didcomm = true
[server]
host = "0.0.0.0"
port = 7080
[log]
level = "info"
format = "json"
[secrets]
backend = "aws"
region = "us-east-1"
secret_name = "vta/prod/seed"
[messaging]
kind = "create_mediator"
context = "mediator"
url = "https://mediator.example.com"
[vta_did]
kind = "create_webvh"
url = "https://trust.example.com/dids/vta"
portable = true
pre_rotation_count = 2
"#;
let inputs = parse(raw).expect("full inputs should parse");
assert_eq!(inputs.vta_name.as_deref(), Some("trust-prod-1"));
assert!(matches!(inputs.secrets, SecretsBackendInput::Aws { .. }));
validate_inputs(&inputs).expect("full inputs should validate");
}
#[test]
fn create_webvh_advanced_fields_default_absent() {
let raw = r#"
config_path = "/tmp/vta-test/config.toml"
data_dir = "/tmp/vta-test/data"
public_url = "https://trust.example.com"
[secrets]
backend = "keyring"
[vta_did]
kind = "create_webvh"
url = "https://trust.example.com/dids/vta"
"#;
let inputs = parse(raw).expect("simple create_webvh should parse");
match &inputs.vta_did {
VtaDidInput::CreateWebvh {
did_document_file,
did_log_file,
signing_key_id,
ka_key_id,
portable,
pre_rotation_count,
..
} => {
assert!(did_document_file.is_none());
assert!(did_log_file.is_none());
assert!(signing_key_id.is_none());
assert!(ka_key_id.is_none());
assert!(*portable, "portable defaults true");
assert_eq!(*pre_rotation_count, 1, "pre_rotation_count defaults 1");
}
other => panic!("expected CreateWebvh, got {other:?}"),
}
validate_inputs(&inputs).expect("simple create_webvh should validate");
}
#[test]
fn create_webvh_single_advanced_mode_validates() {
for (field, value) in [
("did_document_file", "\"/tmp/doc.json\""),
("did_log_file", "\"/tmp/did.jsonl\""),
("signing_key_id", "\"did:key:z6MkSigner#key-0\""),
] {
let raw = format!(
r#"
config_path = "/tmp/vta-test/config.toml"
data_dir = "/tmp/vta-test/data"
public_url = "https://trust.example.com"
[secrets]
backend = "keyring"
[vta_did]
kind = "create_webvh"
url = "https://trust.example.com/dids/vta"
{field} = {value}
"#
);
let inputs = parse(&raw).unwrap_or_else(|e| panic!("{field} should parse: {e}"));
validate_inputs(&inputs)
.unwrap_or_else(|e| panic!("{field} alone should validate: {e}"));
}
}
#[test]
fn create_webvh_conflicting_advanced_modes_rejected() {
let raw = r#"
config_path = "/tmp/vta-test/config.toml"
data_dir = "/tmp/vta-test/data"
public_url = "https://trust.example.com"
[secrets]
backend = "keyring"
[vta_did]
kind = "create_webvh"
url = "https://trust.example.com/dids/vta"
did_document_file = "/tmp/doc.json"
signing_key_id = "did:key:z6MkSigner#key-0"
"#;
let inputs = parse(raw).expect("conflicting advanced modes should still parse");
let err =
validate_inputs(&inputs).expect_err("conflicting advanced modes must be rejected");
assert!(err.to_string().contains("mutually-exclusive"), "got: {err}");
}
#[test]
fn create_webvh_ka_key_without_signing_key_rejected() {
let raw = r#"
config_path = "/tmp/vta-test/config.toml"
data_dir = "/tmp/vta-test/data"
public_url = "https://trust.example.com"
[secrets]
backend = "keyring"
[vta_did]
kind = "create_webvh"
url = "https://trust.example.com/dids/vta"
ka_key_id = "did:key:z6MkKA#key-1"
"#;
let inputs = parse(raw).expect("ka_key_id alone should parse");
let err = validate_inputs(&inputs)
.expect_err("ka_key_id without signing_key_id must be rejected");
assert!(err.to_string().contains("ka_key_id requires"), "got: {err}");
}
#[test]
fn silent_ui_behaviour() {
let ui = super::super::SilentUi;
let mnemonic = super::super::generate_mnemonic_silent().expect("mnemonic");
ui.confirm_mnemonic(&mnemonic)
.expect("SilentUi must never block on mnemonic confirmation");
let canonical = std::path::Path::new("/data/did-logs/vta-did.jsonl");
assert_eq!(
ui.did_log_path("vta", canonical),
Some(canonical.to_path_buf()),
"SilentUi must echo the canonical did.jsonl path"
);
}
}