use std::collections::HashMap;
use std::path::{Path, PathBuf};
use bip39::Mnemonic;
use dialoguer::{Confirm, Input, MultiSelect, Select};
use serde_json::json;
use crate::config::{
AuditConfig, HardenedConfig, LogConfig, LogFormat, ServerConfig, ServicesConfig,
};
use super::{
SetupUi, apply_inputs, derive_ws_url,
from_toml::{
ExistingDataDirPolicy, MessagingInput, SecretsBackendInput, VtaDidInput, WizardInputs,
},
};
type DynErr = Box<dyn std::error::Error>;
pub(crate) trait Prompter {
fn text(
&self,
prompt: &str,
default: Option<&str>,
allow_empty: bool,
validate: Option<&dyn Fn(&str) -> Result<(), String>>,
) -> Result<String, DynErr>;
fn confirm(&self, prompt: &str, default: bool) -> Result<bool, DynErr>;
fn select(&self, prompt: &str, items: &[&str], default: usize) -> Result<usize, DynErr>;
fn multiselect(
&self,
prompt: &str,
items: &[&str],
defaults: &[bool],
) -> Result<Vec<usize>, DynErr>;
}
pub(crate) struct DialoguerPrompter;
impl Prompter for DialoguerPrompter {
fn text(
&self,
prompt: &str,
default: Option<&str>,
allow_empty: bool,
validate: Option<&dyn Fn(&str) -> Result<(), String>>,
) -> Result<String, DynErr> {
let mut input = Input::<String>::new().with_prompt(prompt);
if let Some(d) = default {
input = input.default(d.to_owned());
}
if allow_empty {
input = input.allow_empty(true);
}
let out = match validate {
Some(v) => input
.validate_with(move |s: &String| v(s.as_str()))
.interact_text()?,
None => input.interact_text()?,
};
Ok(out)
}
fn confirm(&self, prompt: &str, default: bool) -> Result<bool, DynErr> {
Ok(Confirm::new()
.with_prompt(prompt)
.default(default)
.interact()?)
}
fn select(&self, prompt: &str, items: &[&str], default: usize) -> Result<usize, DynErr> {
Ok(Select::new()
.with_prompt(prompt)
.items(items)
.default(default)
.interact()?)
}
fn multiselect(
&self,
prompt: &str,
items: &[&str],
defaults: &[bool],
) -> Result<Vec<usize>, DynErr> {
Ok(MultiSelect::new()
.with_prompt(prompt)
.items(items)
.defaults(defaults)
.interact()?)
}
}
struct InteractiveUi<'p> {
prompter: &'p dyn Prompter,
}
impl SetupUi for InteractiveUi<'_> {
fn confirm_mnemonic(&self, mnemonic: &Mnemonic) -> Result<(), DynErr> {
eprintln!();
eprintln!("\x1b[1;33m╔══════════════════════════════════════════════════════════╗");
eprintln!("║ WARNING: Write down your mnemonic phrase and store it ║");
eprintln!("║ securely. It is the ONLY way to recover your keys. ║");
eprintln!("╚══════════════════════════════════════════════════════════╝\x1b[0m");
eprintln!();
eprintln!("\x1b[1m{mnemonic}\x1b[0m");
eprintln!();
if self
.prompter
.confirm("I have saved my mnemonic phrase", false)?
{
Ok(())
} else {
Err("Setup cancelled — please save your mnemonic before proceeding.".into())
}
}
fn did_log_path(&self, label: &str, _default: &Path) -> Option<PathBuf> {
let default_file = format!("{label}-did.jsonl");
match self
.prompter
.text("Save DID log to file", Some(&default_file), false, None)
{
Ok(entered) => {
eprintln!();
eprintln!(
" \x1b[2mTo self-host this DID, upload {entered} to the DID URL.\x1b[0m"
);
Some(PathBuf::from(entered))
}
Err(_) => Some(_default.to_path_buf()),
}
}
}
fn prompt_hardened(p: &dyn Prompter) -> Result<HardenedConfig, DynErr> {
let enabled = p.confirm(
"Enable hardened configuration? (encrypts the fjall store and seals the JWT signing key \
in the bootstrap keyspace — protects against direct disk access)",
false,
)?;
if !enabled {
return Ok(HardenedConfig::default());
}
eprintln!();
eprintln!(" \x1b[2mHardened configuration enabled.\x1b[0m");
eprintln!(
" \x1b[2mA random storage_key_salt has been generated and written to config.toml.\x1b[0m"
);
eprintln!(
" \x1b[2mBack up config.toml: changing or losing the salt makes the store \
unreadable.\x1b[0m"
);
eprintln!(
" \x1b[2mTo supply your own storage_key_salt, run `vta setup --from <toml>` \
instead.\x1b[0m"
);
eprintln!();
Ok(HardenedConfig {
enabled: true,
storage_key_salt: crate::hardened_bootstrap::generate_storage_key_salt(),
})
}
const REST_ITEM: &str = "REST API";
const DIDCOMM_ITEM: &str = "DIDComm Messaging";
const TSP_ITEM: &str = "TSP (Trust Spanning Protocol)";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) struct ServiceSelection {
pub rest: bool,
pub didcomm: bool,
pub tsp: bool,
}
fn service_items() -> Vec<&'static str> {
#[allow(unused_mut)]
let mut items = vec![REST_ITEM, DIDCOMM_ITEM];
#[cfg(feature = "tsp")]
items.push(TSP_ITEM);
items
}
fn services_from_selection(items: &[&str], selected: &[usize]) -> ServiceSelection {
let picked = |label: &str| {
items
.iter()
.position(|it| *it == label)
.is_some_and(|i| selected.contains(&i))
};
ServiceSelection {
rest: picked(REST_ITEM),
didcomm: picked(DIDCOMM_ITEM),
tsp: picked(TSP_ITEM),
}
}
fn validate_services(sel: &ServiceSelection) -> Result<(), String> {
if !sel.rest && !sel.didcomm && !sel.tsp {
return Err("Please select at least one service.".into());
}
Ok(())
}
fn keep_tsp_after_mediator_check(
did: &str,
verdict: &super::MediatorTsp,
another_transport: bool,
) -> Result<bool, DynErr> {
match verdict {
super::MediatorTsp::Carried => {
eprintln!(" \x1b[2mMediator {did} advertises TSP.\x1b[0m");
Ok(true)
}
super::MediatorTsp::NotCarried if !another_transport => {
Err(super::from_toml::mediator_lacks_tsp(did).into())
}
super::MediatorTsp::NotCarried => {
eprintln!(
" \x1b[33mMediator {did} advertises no TSPTransport service, so it does not \
route TSP. TSP will not be advertised; add it later with `pnm services tsp \
enable` once the mediator carries it.\x1b[0m"
);
Ok(false)
}
super::MediatorTsp::Unknown(why) => {
eprintln!(
" \x1b[33mWarning: {why}. Could not confirm that mediator {did} carries TSP; \
`#tsp` will be advertised through it regardless. If it does not route TSP, \
run `pnm services tsp disable` once the VTA is up.\x1b[0m"
);
Ok(true)
}
}
}
fn prompt_services(p: &dyn Prompter) -> Result<ServiceSelection, DynErr> {
let items = service_items();
let defaults: Vec<bool> = vec![true; items.len()];
loop {
let selected = p.multiselect(
"Services to enable (select at least one)",
&items,
&defaults,
)?;
let selection = services_from_selection(&items, &selected);
if let Err(msg) = validate_services(&selection) {
eprintln!("\x1b[31m{msg}\x1b[0m");
continue;
}
if selection.tsp {
eprintln!();
eprintln!(
" \x1b[2mTSP will be advertised as `#tsp` in the VTA DID document, \
pointing at the same mediator as DIDComm.\x1b[0m"
);
eprintln!(
" \x1b[2mAn existing mediator is checked for TSP once you name it; a mediator \
created here is created with it.\x1b[0m"
);
eprintln!(
" \x1b[2mThis is changeable later: `pnm services tsp enable` / \
`disable`.\x1b[0m"
);
eprintln!();
}
return Ok(selection);
}
}
async fn configure_secrets(p: &dyn Prompter) -> Result<SecretsBackendInput, DynErr> {
let mut labels: Vec<&str> = Vec::new();
let mut tags: Vec<&str> = Vec::new();
#[cfg(feature = "aws-secrets")]
{
labels.push("AWS Secrets Manager");
tags.push("aws");
}
#[cfg(feature = "gcp-secrets")]
{
labels.push("GCP Secret Manager");
tags.push("gcp");
}
#[cfg(feature = "azure-secrets")]
{
labels.push("Azure Key Vault");
tags.push("azure");
}
#[cfg(feature = "vault-secrets")]
{
labels.push("HashiCorp Vault");
tags.push("vault");
}
#[cfg(feature = "k8s-secrets")]
{
labels.push("Kubernetes Secret");
tags.push("k8s");
}
#[cfg(feature = "config-seed")]
{
labels.push("Config file (hex-encoded seed in config.toml)");
tags.push("config");
}
#[cfg(feature = "keyring")]
{
labels.push("OS keyring");
tags.push("keyring");
}
labels.push("Plaintext file (NOT recommended)");
tags.push("plaintext");
let choice = if labels.len() == 1 {
0
} else {
p.select("Seed storage backend", &labels, 0)?
};
let tag = tags[choice];
#[cfg(feature = "aws-secrets")]
if tag == "aws" {
return prompt_aws_secrets(p).await;
}
#[cfg(feature = "gcp-secrets")]
if tag == "gcp" {
return prompt_gcp_secrets(p).await;
}
#[cfg(feature = "azure-secrets")]
if tag == "azure" {
return prompt_azure_secrets(p);
}
#[cfg(feature = "vault-secrets")]
if tag == "vault" {
return prompt_vault_secrets(p);
}
#[cfg(feature = "k8s-secrets")]
if tag == "k8s" {
return prompt_k8s_secrets(p);
}
#[cfg(feature = "config-seed")]
if tag == "config" {
return Ok(SecretsBackendInput::ConfigSeed);
}
#[cfg(feature = "keyring")]
if tag == "keyring" {
let service = p.text(
"Keyring service name (use a unique name per VTA instance)",
Some("vta"),
false,
None,
)?;
return Ok(SecretsBackendInput::Keyring { service });
}
if tag == "plaintext" {
eprintln!();
eprintln!("\x1b[1;33m╔══════════════════════════════════════════════════════════╗");
eprintln!("║ WARNING: Plaintext storage is NOT secure. ║");
eprintln!("║ Seeds will be stored in a plaintext file on disk. ║");
eprintln!("║ Use only for development or testing. ║");
eprintln!("╚══════════════════════════════════════════════════════════╝\x1b[0m");
eprintln!();
return Ok(SecretsBackendInput::Plaintext);
}
unreachable!("selected backend tag does not match any compiled feature")
}
#[cfg(feature = "aws-secrets")]
async fn prompt_aws_secrets(p: &dyn Prompter) -> Result<SecretsBackendInput, DynErr> {
let region = p.text("AWS region (leave empty for SDK default)", None, true, None)?;
let region = if region.is_empty() {
None
} else {
Some(region)
};
let secret_name = pick_or_enter_secret(
p,
vti_secrets::discovery::list_aws_secrets(region.as_deref())
.await
.map_err(Into::into),
)
.await?;
Ok(SecretsBackendInput::Aws {
region,
secret_name,
})
}
#[cfg(feature = "gcp-secrets")]
async fn prompt_gcp_secrets(p: &dyn Prompter) -> Result<SecretsBackendInput, DynErr> {
let project = p.text("GCP project ID", None, false, None)?;
let secret_name = pick_or_enter_secret(
p,
vti_secrets::discovery::list_gcp_secrets(&project)
.await
.map_err(Into::into),
)
.await?;
Ok(SecretsBackendInput::Gcp {
project,
secret_name,
})
}
#[cfg(any(feature = "aws-secrets", feature = "gcp-secrets"))]
async fn pick_or_enter_secret(
p: &dyn Prompter,
listed: Result<Vec<String>, DynErr>,
) -> Result<String, DynErr> {
match listed {
Ok(names) if !names.is_empty() => {
let mut items: Vec<String> = names;
items.push("Create new secret".into());
let item_refs: Vec<&str> = items.iter().map(String::as_str).collect();
let choice = p.select(
"Select an existing secret or create a new one",
&item_refs,
0,
)?;
if choice == items.len() - 1 {
p.text("Secret name", Some("vta-master-seed"), false, None)
} else {
Ok(items.swap_remove(choice))
}
}
Ok(_) => {
eprintln!(" No existing secrets found.");
p.text("Secret name", Some("vta-master-seed"), false, None)
}
Err(e) => {
eprintln!(" Warning: could not list secrets: {e}");
p.text("Secret name", Some("vta-master-seed"), false, None)
}
}
}
#[cfg(feature = "azure-secrets")]
fn prompt_azure_secrets(p: &dyn Prompter) -> Result<SecretsBackendInput, DynErr> {
let vault_url = p.text(
"Azure Key Vault URL (e.g. https://my-vault.vault.azure.net)",
None,
false,
None,
)?;
let secret_name = p.text(
"Azure Key Vault secret name",
Some("vta-master-seed"),
false,
None,
)?;
Ok(SecretsBackendInput::Azure {
vault_url,
secret_name,
})
}
#[cfg(feature = "vault-secrets")]
fn prompt_vault_secrets(p: &dyn Prompter) -> Result<SecretsBackendInput, DynErr> {
use super::from_toml::{
default_vault_approle_mount, default_vault_k8s_jwt_path, default_vault_k8s_mount,
};
let addr = p.text(
"Vault server URL (e.g. https://vault.example.com:8200)",
None,
false,
None,
)?;
let secret_path = p.text(
"KV v2 secret path (e.g. vta/master-seed)",
None,
false,
None,
)?;
let kv_mount = p.text("KV v2 mount path", Some("secret"), false, None)?;
let secret_key = p.text(
"Field name within the KV entry holding the hex seed",
Some("seed"),
false,
None,
)?;
let namespace = p.text(
"Vault Enterprise namespace (leave empty if not using)",
None,
true,
None,
)?;
let namespace = if namespace.is_empty() {
None
} else {
Some(namespace)
};
let auth_methods = ["kubernetes", "token", "approle"];
let auth_idx = p.select("Auth method", &auth_methods, 0)?;
let auth_method = auth_methods[auth_idx].to_string();
let mut k8s_role = None;
let mut token = None;
let mut approle_role_id = None;
let mut approle_secret_id = None;
match auth_method.as_str() {
"kubernetes" => {
k8s_role = Some(p.text("Kubernetes auth role name", None, false, None)?);
}
"token" => {
eprintln!(
" \x1b[2mLeave empty to read from the VAULT_TOKEN env var at runtime.\x1b[0m"
);
let t = p.text("Vault token", None, true, None)?;
if !t.is_empty() {
token = Some(t);
}
}
"approle" => {
approle_role_id = Some(p.text("AppRole role_id", None, false, None)?);
approle_secret_id = Some(p.text("AppRole secret_id", None, false, None)?);
}
_ => unreachable!("auth_method came from a fixed list"),
}
Ok(SecretsBackendInput::Vault {
addr,
secret_path,
kv_mount,
secret_key,
namespace,
auth_method,
k8s_role,
k8s_mount: default_vault_k8s_mount(),
k8s_jwt_path: default_vault_k8s_jwt_path(),
token,
approle_role_id,
approle_secret_id,
approle_mount: default_vault_approle_mount(),
skip_verify: false,
})
}
#[cfg(feature = "k8s-secrets")]
fn prompt_k8s_secrets(p: &dyn Prompter) -> Result<SecretsBackendInput, DynErr> {
use super::from_toml::default_k8s_secret_key;
let secret_name = p.text(
"Kubernetes Secret name",
Some("vta-master-seed"),
false,
None,
)?;
let namespace = p.text(
"Namespace (leave empty to use the pod's ServiceAccount namespace)",
None,
true,
None,
)?;
let namespace = if namespace.is_empty() {
None
} else {
Some(namespace)
};
let secret_key = p.text(
"Key within the Secret's data map holding the hex seed",
Some("seed"),
false,
None,
)?;
let secret_key = if secret_key.is_empty() {
default_k8s_secret_key()
} else {
secret_key
};
Ok(SecretsBackendInput::Kubernetes {
secret_name,
namespace,
secret_key,
})
}
fn prompt_optional_mediator_host(p: &dyn Prompter) -> Result<Option<String>, DynErr> {
let host = p.text(
"Mediator hostname for vsock-bridged TEE deployments (leave empty to skip)",
None,
true,
None,
)?;
Ok(if host.is_empty() { None } else { Some(host) })
}
async fn configure_messaging(p: &dyn Prompter) -> Result<MessagingInput, DynErr> {
let options = [
"Use an existing mediator DID",
"Create a new mediator DID (did:webvh)",
"Do not use DIDComm messaging",
];
let choice = p.select("DIDComm messaging", &options, 0)?;
match choice {
0 => {
let did = p.text(
"Mediator DID",
None,
false,
Some(&|s: &str| {
if s.starts_with("did:") {
Ok(())
} else {
Err("DID must start with 'did:' (e.g. did:webvh:... or did:key:...)".into())
}
}),
)?;
let mediator_host = prompt_optional_mediator_host(p)?;
let setup_acl = p.confirm(
"Automatically provision ACL on mediator after connecting? \
(enable if mediator uses ExplicitAllow mode)",
false,
)?;
Ok(MessagingInput::Existing {
did,
mediator_host,
setup_acl,
})
}
1 => {
let context = p
.text(
"Trust context for the mediator DID",
Some("mediator"),
false,
None,
)?
.trim()
.to_string();
if context.is_empty() {
return Err("mediator context id cannot be empty".into());
}
let url = p.text("Mediator URL", None, false, None)?;
let ws_default = derive_ws_url(&url);
let ws_url = p.text("Mediator WebSocket URL", ws_default.as_deref(), false, None)?;
let webvh_url = prompt_webvh_url(p, &context, Some(&url))?;
let mediator_host = prompt_optional_mediator_host(p)?;
let routing_raw = p.text(
"Upstream routing-key DIDs for this mediator (comma-separated, leave empty to skip)",
None,
true,
None,
)?;
let routing_keys: Vec<String> = routing_raw
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from)
.collect();
let mut template_vars: HashMap<String, serde_json::Value> = HashMap::new();
if !routing_keys.is_empty() {
template_vars.insert("ROUTING_KEYS".into(), json!(routing_keys));
}
let setup_acl = p.confirm(
"Automatically provision ACL on mediator after connecting? \
(enable if mediator uses ExplicitAllow mode)",
false,
)?;
Ok(MessagingInput::CreateMediator {
context,
url,
ws_url: Some(ws_url),
webvh_url: Some(webvh_url),
mediator_host,
template_vars,
setup_acl,
protocols: None,
})
}
_ => Ok(MessagingInput::Skip),
}
}
fn create_vta_did(p: &dyn Prompter) -> Result<VtaDidInput, DynErr> {
let did_options = [
"Create a new did:webvh DID (recommended for production)",
"Create a new did:key (no external hosting; great for local dev)",
"Enter an existing DID",
"Skip (no VTA DID for now)",
];
let choice = p.select("VTA DID", &did_options, 0)?;
match choice {
0 => {
let url = prompt_webvh_url(p, "VTA", None)?;
let mode_options = [
"Simple — VTA creates keys and document (recommended)",
"Advanced — provide your own document, keys, or pre-signed log",
];
let advanced = p.select("DID creation mode", &mode_options, 0)? == 1;
let (did_document_file, did_log_file, signing_key_id, ka_key_id) = if advanced {
let adv_options = [
"Provide a DID Document template (VTA signs it)",
"Import a pre-signed did.jsonl",
"Use existing imported keys",
];
match p.select("Advanced option", &adv_options, 0)? {
0 => {
let path = p.text("Path to DID Document JSON file", None, false, None)?;
(Some(PathBuf::from(path)), None, None, None)
}
1 => {
let path = p.text("Path to did.jsonl file", None, false, None)?;
(None, Some(PathBuf::from(path)), None, None)
}
_ => {
let signing = p.text("Signing key ID (Ed25519)", None, false, None)?;
let ka = p.text(
"Key-agreement key ID (X25519, leave empty to skip)",
None,
true,
None,
)?;
let ka_id = if ka.is_empty() { None } else { Some(ka) };
(None, None, Some(signing), ka_id)
}
}
} else {
(None, None, None, None)
};
let (portable, pre_rotation_count) = if did_log_file.is_none() {
let portable = p.confirm(
"Make this DID portable (can move to a different domain later)?",
true,
)?;
eprintln!();
eprintln!(
" \x1b[2mPre-rotation protects against key compromise by publishing hashes"
);
eprintln!(" of future keys now. Recommended: 1-3 keys.\x1b[0m");
let pre_rotation_count = p
.text(
"Number of pre-rotation keys",
Some("1"),
false,
Some(&|s: &str| {
s.parse::<u32>()
.map(|_| ())
.map_err(|e| format!("must be a non-negative integer: {e}"))
}),
)?
.parse()
.expect("validated above");
(portable, pre_rotation_count)
} else {
(true, 0)
};
Ok(VtaDidInput::CreateWebvh {
url,
portable,
pre_rotation_count,
did_document_file,
did_log_file,
signing_key_id,
ka_key_id,
})
}
1 => Ok(VtaDidInput::CreateDidKey),
2 => {
let did = p.text("VTA DID", None, false, None)?;
Ok(VtaDidInput::Existing { did })
}
_ => Ok(VtaDidInput::Skip),
}
}
fn prompt_webvh_url(
p: &dyn Prompter,
label: &str,
default: Option<&str>,
) -> Result<String, DynErr> {
eprintln!();
eprintln!(" Enter the URL where the {label} DID document will be hosted.");
eprintln!(" Examples:");
eprintln!(" https://example.com -> did:webvh:{{SCID}}:example.com");
eprintln!(" https://example.com/dids/vta -> did:webvh:{{SCID}}:example.com:dids:vta");
eprintln!(" http://localhost:8000 -> did:webvh:{{SCID}}:localhost%3A8000");
eprintln!();
let url = p.text(
&format!("{label} DID URL"),
default.or(Some("http://localhost:8000/")),
false,
Some(&|s: &str| {
let parsed = url::Url::parse(s).map_err(|e| format!("invalid URL: {e}"))?;
didwebvh_rs::url::WebVHURL::parse_url(&parsed)
.map(|_| ())
.map_err(|e| format!("could not convert to a webvh DID: {e}"))
}),
)?;
Ok(url)
}
async fn gather_inputs(
p: &dyn Prompter,
default_config_path: Option<PathBuf>,
) -> Result<Option<WizardInputs>, DynErr> {
let default_path = default_config_path
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|| {
std::env::var("VTA_CONFIG_PATH").unwrap_or_else(|_| "config.toml".into())
});
let config_path = PathBuf::from(
p.text("Config file path", Some(&default_path), false, None)?
.trim(),
);
let mut overwrite_config = false;
if config_path.exists() {
if !p.confirm(
&format!("{} already exists. Overwrite?", config_path.display()),
false,
)? {
eprintln!("Setup cancelled.");
return Ok(None);
}
overwrite_config = true;
}
let vta_name = p.text("VTA name (leave empty to skip)", None, true, None)?;
let vta_name = if vta_name.is_empty() {
None
} else {
Some(vta_name)
};
let ServiceSelection {
rest: enable_rest,
didcomm: enable_didcomm,
tsp: mut enable_tsp,
} = prompt_services(p)?;
let (public_url, host, port) = if enable_rest {
let host = p.text("Server host", Some("0.0.0.0"), false, None)?;
let port: u16 = p
.text(
"Server port",
Some("8100"),
false,
Some(&|s: &str| {
s.parse::<u16>()
.map(|_| ())
.map_err(|e| format!("invalid port: {e}"))
}),
)?
.parse()
.expect("validated above");
eprintln!();
eprintln!(
" REST is enabled — the VTA needs a public URL to publish as a service endpoint in its DID document. Other parties (CLI clients, other VTAs) resolve the DID and use this URL to reach the REST API."
);
eprintln!(" Examples:");
eprintln!(" • Local development: http://localhost:{port}");
eprintln!(" • Production: https://vta.example.com");
eprintln!();
let default_url = format!("http://localhost:{port}");
let public_url = p.text(
"VTA REST URL",
Some(&default_url),
false,
Some(&|s: &str| {
let s = s.trim();
if s.is_empty() {
Err("VTA REST URL is required when REST is enabled".into())
} else if !(s.starts_with("http://") || s.starts_with("https://")) {
Err(
"URL must start with http:// or https:// (e.g. http://localhost:8100)"
.into(),
)
} else {
Ok(())
}
}),
)?;
let public_url = public_url.trim().trim_end_matches('/').to_string();
(Some(public_url), host, port)
} else {
(
None,
ServerConfig::default().host,
ServerConfig::default().port,
)
};
let log_level = p.text("Log level", Some("info"), false, None)?;
let log_format = match p.select("Log format", &["text", "json"], 0)? {
1 => LogFormat::Json,
_ => LogFormat::Text,
};
#[cfg(feature = "tee")]
let resolver_url = {
eprintln!();
eprintln!("DID resolution");
eprintln!(" In a TEE the enclave cannot reach the network directly, so DID");
eprintln!(" resolution is dispatched to an external resolver-cache-server on the");
eprintln!(" parent, bridged over vsock. Example: ws://127.0.0.1:4445/did/v1/ws");
eprintln!();
let entered = p.text("Remote DID resolver WebSocket URL", None, true, None)?;
if entered.is_empty() {
None
} else {
Some(entered)
}
};
#[cfg(not(feature = "tee"))]
let resolver_url: Option<String> = None;
let retention_days: u32 = p
.text(
"Audit-log retention (days)",
Some(&AuditConfig::default().retention_days.to_string()),
false,
Some(&|s: &str| match s.parse::<u32>() {
Ok(0) => {
Err("retention must be > 0; the audit sweeper assumes a positive window".into())
}
Ok(_) => Ok(()),
Err(e) => Err(format!("invalid number: {e}")),
}),
)?
.parse()
.expect("validated above");
let audit = AuditConfig { retention_days };
let data_dir = PathBuf::from(
p.text("Data directory", Some("data/vta"), false, None)?
.trim(),
);
let mut data_dir_exists = ExistingDataDirPolicy::default();
if vti_common::store::local_store_exists(&data_dir) {
eprintln!();
eprintln!(
" Data directory \"{}\" already contains a VTA store.",
data_dir.display()
);
eprintln!();
let choice = p.select(
"How should setup proceed?",
&[
"Cancel setup (leave the existing data untouched)",
"Keep the existing contents and initialize into it",
"Delete everything in it and start fresh",
],
0,
)?;
match choice {
1 => data_dir_exists = ExistingDataDirPolicy::Reuse,
2 => data_dir_exists = ExistingDataDirPolicy::Delete,
_ => {
eprintln!("Setup cancelled.");
return Ok(None);
}
}
}
let (cors_origins, trust_xff_cidrs, webauthn) = if enable_rest
&& p.confirm(
"Configure advanced server options (CORS, trusted proxy CIDRs, WebAuthn)?",
false,
)? {
let cors_raw = p.text(
"Allowed CORS origins (comma-separated, leave empty for none)",
None,
true,
None,
)?;
let cors_origins: Vec<String> = cors_raw
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from)
.collect();
let cidrs_raw = p.text(
"Trusted proxy CIDRs (comma-separated, e.g. 10.0.0.0/24 — every \
reverse proxy in front of this VTA, and only ones that rewrite \
X-Forwarded-For; a plain TCP forwarder listed here is a \
rate-limit bypass. Leave empty to key on the raw peer IP)",
None,
true,
Some(&|s: &str| {
for entry in s.split(',').map(str::trim).filter(|s| !s.is_empty()) {
entry
.parse::<ipnetwork::IpNetwork>()
.map_err(|e| format!("'{entry}' is not a valid CIDR: {e}"))?;
}
Ok(())
}),
)?;
let trust_xff_cidrs: Vec<ipnetwork::IpNetwork> = cidrs_raw
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.parse().expect("validated above"))
.collect();
let webauthn = p.confirm("Advertise a WebAuthn-RP service in the VTA DID?", false)?;
(cors_origins, trust_xff_cidrs, webauthn)
} else {
(Vec::new(), Vec::new(), false)
};
let secrets = configure_secrets(p).await?;
let hardened = prompt_hardened(p)?;
let messaging = if enable_didcomm || enable_tsp {
if enable_tsp && !enable_didcomm {
eprintln!();
eprintln!(
" \x1b[2mTSP rides a mediator, the same way DIDComm does — `#tsp` in your DID \
document names the mediator's DID, and the transport URL lives in the \
mediator's own document.\x1b[0m"
);
eprintln!(
" \x1b[2mThe questions below configure that mediator. It must route TSP; an \
existing one is checked for it.\x1b[0m"
);
}
configure_messaging(p).await?
} else {
MessagingInput::Skip
};
if enable_tsp && let MessagingInput::Existing { did, .. } = &messaging {
let verdict = super::existing_mediator_tsp(did, resolver_url.as_deref()).await;
enable_tsp = keep_tsp_after_mediator_check(did, &verdict, enable_rest || enable_didcomm)?;
}
let vta_did = create_vta_did(p)?;
Ok(Some(WizardInputs {
config_path,
overwrite_config,
vta_name,
public_url,
data_dir,
data_dir_exists,
services: ServicesConfig {
rest: enable_rest,
didcomm: enable_didcomm,
webauthn,
tsp: enable_tsp,
},
server: ServerConfig {
host,
port,
cors_origins,
trust_xff_cidrs,
rate_limit_interval_secs: ServerConfig::default().rate_limit_interval_secs,
rate_limit_burst: ServerConfig::default().rate_limit_burst,
did_log_rate_limit_interval_secs: ServerConfig::default()
.did_log_rate_limit_interval_secs,
did_log_rate_limit_burst: ServerConfig::default().did_log_rate_limit_burst,
trust_xff: (),
},
log: LogConfig {
level: log_level,
format: log_format,
},
secrets,
messaging,
vta_did,
admin_did: None,
admin_label: None,
resolver_url,
audit,
staff: Vec::new(),
hardened,
}))
}
pub async fn run_setup_wizard(config_path: Option<PathBuf>) -> Result<(), DynErr> {
eprintln!("Welcome to the VTA setup wizard.\n");
let prompter = DialoguerPrompter;
let Some(inputs) = gather_inputs(&prompter, config_path).await? else {
return Ok(());
};
apply_inputs(
inputs,
&InteractiveUi {
prompter: &prompter,
},
)
.await
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
use std::collections::VecDeque;
#[derive(Clone)]
enum Answer {
Text(String),
Bool(bool),
Index(usize),
Label(&'static str),
Labels(Vec<&'static str>),
}
struct ScriptedPrompter {
answers: RefCell<VecDeque<Answer>>,
}
impl ScriptedPrompter {
fn new(answers: Vec<Answer>) -> Self {
Self {
answers: RefCell::new(answers.into()),
}
}
fn next(&self, prompt: &str) -> Answer {
self.answers
.borrow_mut()
.pop_front()
.unwrap_or_else(|| panic!("script exhausted at prompt: {prompt}"))
}
}
impl Prompter for ScriptedPrompter {
fn text(
&self,
prompt: &str,
_default: Option<&str>,
_allow_empty: bool,
validate: Option<&dyn Fn(&str) -> Result<(), String>>,
) -> Result<String, DynErr> {
match self.next(prompt) {
Answer::Text(s) => {
if let Some(v) = validate {
v(&s)
.map_err(|e| format!("scripted answer {s:?} failed validation: {e}"))?;
}
Ok(s)
}
_ => panic!("expected Text answer for prompt: {prompt}"),
}
}
fn confirm(&self, prompt: &str, _default: bool) -> Result<bool, DynErr> {
match self.next(prompt) {
Answer::Bool(b) => Ok(b),
_ => panic!("expected Bool answer for prompt: {prompt}"),
}
}
fn select(&self, prompt: &str, items: &[&str], _default: usize) -> Result<usize, DynErr> {
match self.next(prompt) {
Answer::Index(i) => Ok(i),
Answer::Label(want) => items.iter().position(|it| *it == want).ok_or_else(|| {
format!(
"scripted label {want:?} is not an option for prompt {prompt:?}; \
available: {items:?}"
)
.into()
}),
_ => panic!("expected Index or Label answer for prompt: {prompt}"),
}
}
fn multiselect(
&self,
prompt: &str,
items: &[&str],
_defaults: &[bool],
) -> Result<Vec<usize>, DynErr> {
match self.next(prompt) {
Answer::Labels(want) => want
.iter()
.map(|w| {
items.iter().position(|it| it == w).ok_or_else(|| {
format!(
"scripted label {w:?} is not an option for prompt {prompt:?}; \
available: {items:?}"
)
.into()
})
})
.collect(),
_ => panic!("expected Labels answer for prompt: {prompt}"),
}
}
}
fn text(s: &str) -> Answer {
Answer::Text(s.to_string())
}
#[tokio::test]
async fn interactive_matches_equivalent_toml() {
let answers = vec![
text("/tmp/vta-golden/config.toml"), text("golden-vta"), Answer::Labels(vec![REST_ITEM, DIDCOMM_ITEM]), text("0.0.0.0"), text("8100"), text("https://trust.example.com"), text("info"), Answer::Index(0), #[cfg(feature = "tee")]
text(""), text("90"), text("/tmp/vta-golden/data"), Answer::Bool(true), text("https://app.example.com"), text("10.0.0.0/24"), Answer::Bool(true), Answer::Label("OS keyring"),
text("golden-keyring"), Answer::Bool(false), Answer::Index(1), text("mediator"), text("https://mediator.example.com"), text("wss://mediator.example.com/ws"), text("https://dids.example.com/mediator"), text(""), text(""), Answer::Bool(false), Answer::Index(1), ];
let p = ScriptedPrompter::new(answers);
let gathered = gather_inputs(&p, None)
.await
.expect("gather should succeed")
.expect("gather should not cancel");
let toml_str = r#"
config_path = "/tmp/vta-golden/config.toml"
vta_name = "golden-vta"
public_url = "https://trust.example.com"
data_dir = "/tmp/vta-golden/data"
[services]
rest = true
didcomm = true
webauthn = true
# The script above unticks TSP, which a setup file defaults on.
tsp = false
[server]
host = "0.0.0.0"
port = 8100
cors_origins = ["https://app.example.com"]
trust_xff_cidrs = ["10.0.0.0/24"]
[log]
level = "info"
format = "text"
[audit]
retention_days = 90
[secrets]
backend = "keyring"
service = "golden-keyring"
[messaging]
kind = "create_mediator"
context = "mediator"
url = "https://mediator.example.com"
ws_url = "wss://mediator.example.com/ws"
webvh_url = "https://dids.example.com/mediator"
setup_acl = false
[vta_did]
kind = "create_did_key"
"#;
let from_toml: WizardInputs = toml::from_str(toml_str).expect("equivalent TOML parses");
let a = serde_json::to_value(&gathered).unwrap();
let b = serde_json::to_value(&from_toml).unwrap();
assert_eq!(
a, b,
"interactive-gathered inputs must equal the equivalent --from TOML\n\
interactive = {a:#}\n--from = {b:#}"
);
}
#[tokio::test]
async fn a_pre_created_empty_data_dir_is_never_asked_about() {
let dir = tempfile::tempdir().unwrap();
let data_dir = dir.path().join("vta-data");
std::fs::create_dir_all(&data_dir).expect("pre-create the mount point");
let answers = vec![
text(dir.path().join("config.toml").to_str().unwrap()), text(""), Answer::Labels(vec![REST_ITEM]), text("0.0.0.0"), text("8100"), text("https://t.example.com"), text("info"), Answer::Index(0), #[cfg(feature = "tee")]
text(""), text("28"), text(data_dir.to_str().unwrap()), Answer::Bool(false), Answer::Label("OS keyring"), text("vta"), Answer::Bool(false), Answer::Index(1), ];
let gathered = gather_inputs(&ScriptedPrompter::new(answers), None)
.await
.expect("gather should succeed")
.expect("an empty data directory must not cancel setup");
assert_eq!(
gathered.data_dir_exists,
ExistingDataDirPolicy::Error,
"with nothing to lose the policy stays at its fail-closed default"
);
assert!(!gathered.overwrite_config);
}
#[tokio::test]
async fn cancelling_at_the_data_dir_prompt_leaves_the_config_intact() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
const ORIGINAL: &str = "vta_did = \"did:key:zExistingInstall\"\n";
std::fs::write(&config_path, ORIGINAL).unwrap();
let data_dir = dir.path().join("vta-data");
std::fs::create_dir_all(&data_dir).unwrap();
std::fs::write(data_dir.join("version"), b"fjall").unwrap();
let answers = vec![
text(config_path.to_str().unwrap()), Answer::Bool(true), text(""), Answer::Labels(vec![DIDCOMM_ITEM]), text("info"), Answer::Index(0), #[cfg(feature = "tee")]
text(""), text("28"), text(data_dir.to_str().unwrap()), Answer::Index(0), ];
let gathered = gather_inputs(&ScriptedPrompter::new(answers), None)
.await
.expect("cancelling is not an error");
assert!(gathered.is_none(), "cancelling must abort setup");
assert_eq!(
std::fs::read_to_string(&config_path).unwrap(),
ORIGINAL,
"cancelling must not have touched the operator's config"
);
assert!(
data_dir.join("version").is_file(),
"cancelling must not have touched the operator's data"
);
}
#[tokio::test]
async fn existing_store_offers_reuse_and_delete() {
for (choice, expected) in [
(1, ExistingDataDirPolicy::Reuse),
(2, ExistingDataDirPolicy::Delete),
] {
let dir = tempfile::tempdir().unwrap();
let data_dir = dir.path().join("vta-data");
std::fs::create_dir_all(&data_dir).unwrap();
std::fs::write(data_dir.join("version"), b"fjall").unwrap();
let answers = vec![
text(dir.path().join("config.toml").to_str().unwrap()),
text(""), Answer::Labels(vec![DIDCOMM_ITEM]), text("info"), Answer::Index(0), #[cfg(feature = "tee")]
text(""), text("28"), text(data_dir.to_str().unwrap()), Answer::Index(choice), Answer::Label("OS keyring"), text("vta"), Answer::Bool(false), Answer::Index(2), Answer::Index(1), ];
let gathered = gather_inputs(&ScriptedPrompter::new(answers), None)
.await
.expect("gather should succeed")
.expect("choosing reuse/delete must not cancel");
assert_eq!(gathered.data_dir_exists, expected, "choice {choice}");
}
}
#[tokio::test]
async fn advanced_existing_keys_mode_maps_through() {
let answers = vec![
text("/tmp/vta-golden2/config.toml"),
text(""), Answer::Labels(vec![REST_ITEM]), text("0.0.0.0"), text("8100"), text("https://t.example.com"), text("info"), Answer::Index(0), #[cfg(feature = "tee")]
text(""), text("28"), text("/tmp/vta-golden2/data"), Answer::Bool(false), Answer::Label("OS keyring"),
text("vta"), Answer::Bool(false), Answer::Index(0), text("https://t.example.com/dids/vta"), Answer::Index(1), Answer::Index(2), text("did:key:z6MkSigner#key-0"), text("did:key:z6MkKa#key-1"), Answer::Bool(true), text("2"), ];
let p = ScriptedPrompter::new(answers);
let gathered = gather_inputs(&p, None)
.await
.expect("gather should succeed")
.expect("gather should not cancel");
match gathered.vta_did {
VtaDidInput::CreateWebvh {
ref url,
portable,
pre_rotation_count,
ref did_document_file,
ref did_log_file,
ref signing_key_id,
ref ka_key_id,
} => {
assert_eq!(url, "https://t.example.com/dids/vta");
assert!(portable);
assert_eq!(pre_rotation_count, 2);
assert!(did_document_file.is_none());
assert!(did_log_file.is_none());
assert_eq!(signing_key_id.as_deref(), Some("did:key:z6MkSigner#key-0"));
assert_eq!(ka_key_id.as_deref(), Some("did:key:z6MkKa#key-1"));
}
other => panic!("expected CreateWebvh, got {other:?}"),
}
}
#[test]
fn service_items_lists_tsp_only_in_a_tsp_build() {
let items = service_items();
assert!(items.contains(&REST_ITEM) && items.contains(&DIDCOMM_ITEM));
assert_eq!(
items.contains(&TSP_ITEM),
cfg!(feature = "tsp"),
"a build without the TSP dispatcher must not offer to advertise `#tsp`"
);
}
#[test]
fn services_from_selection_maps_by_label_not_position() {
let without_tsp = [REST_ITEM, DIDCOMM_ITEM];
let with_tsp = [REST_ITEM, DIDCOMM_ITEM, TSP_ITEM];
for items in [&without_tsp[..], &with_tsp[..]] {
let sel = services_from_selection(items, &[1]);
assert_eq!(
sel,
ServiceSelection {
rest: false,
didcomm: true,
tsp: false
},
"items {items:?}"
);
}
assert_eq!(
services_from_selection(&with_tsp, &[1, 2]),
ServiceSelection {
rest: false,
didcomm: true,
tsp: true
}
);
assert_eq!(
services_from_selection(&without_tsp, &[1, 2]),
ServiceSelection {
rest: false,
didcomm: true,
tsp: false
}
);
assert_eq!(
services_from_selection(&with_tsp, &[0, 1, 2]),
ServiceSelection {
rest: true,
didcomm: true,
tsp: true
}
);
}
#[test]
fn validate_services_requires_at_least_one_and_tsp_may_stand_alone() {
let none = ServiceSelection::default();
assert!(
validate_services(&none)
.unwrap_err()
.contains("at least one service")
);
for ok in [
ServiceSelection {
rest: false,
didcomm: false,
tsp: true,
},
ServiceSelection {
rest: true,
didcomm: false,
tsp: true,
},
ServiceSelection {
rest: true,
didcomm: false,
tsp: false,
},
ServiceSelection {
rest: false,
didcomm: true,
tsp: false,
},
ServiceSelection {
rest: true,
didcomm: true,
tsp: true,
},
] {
assert!(validate_services(&ok).is_ok(), "{ok:?} must be accepted");
}
}
#[cfg(feature = "tsp")]
#[tokio::test]
async fn ticking_tsp_reaches_the_plan() {
let dir = tempfile::tempdir().unwrap();
let answers = vec![
text(dir.path().join("config.toml").to_str().unwrap()),
text(""), Answer::Labels(vec![DIDCOMM_ITEM, TSP_ITEM]),
text("info"), Answer::Index(0), #[cfg(feature = "tee")]
text(""), text("28"), text(dir.path().join("data").to_str().unwrap()), Answer::Label("OS keyring"),
text("vta"), Answer::Bool(false), Answer::Index(2), Answer::Index(1), ];
let gathered = gather_inputs(&ScriptedPrompter::new(answers), None)
.await
.expect("gather should succeed")
.expect("gather should not cancel");
assert!(gathered.services.tsp, "ticking TSP must set services.tsp");
assert!(gathered.services.didcomm);
assert!(!gathered.services.rest);
}
#[test]
fn an_existing_mediator_without_tsp_drops_it_from_the_selection() {
use super::super::MediatorTsp;
let did = "did:web:mediator.example";
assert!(keep_tsp_after_mediator_check(did, &MediatorTsp::Carried, true).unwrap());
assert!(!keep_tsp_after_mediator_check(did, &MediatorTsp::NotCarried, true).unwrap());
let err = keep_tsp_after_mediator_check(did, &MediatorTsp::NotCarried, false)
.expect_err("a TSP-only VTA cannot drop its only transport");
assert!(err.to_string().contains("services.tsp = false"), "{err}");
let unknown = MediatorTsp::Unknown("offline".into());
assert!(keep_tsp_after_mediator_check(did, &unknown, true).unwrap());
}
#[cfg(feature = "tsp")]
#[test]
fn tsp_alone_is_accepted_and_an_empty_selection_reasks() {
let p = ScriptedPrompter::new(vec![Answer::Labels(vec![TSP_ITEM])]);
assert_eq!(
prompt_services(&p).expect("TSP alone is a valid selection"),
ServiceSelection {
rest: false,
didcomm: false,
tsp: true
}
);
let p = ScriptedPrompter::new(vec![
Answer::Labels(vec![]), Answer::Labels(vec![TSP_ITEM]),
]);
assert_eq!(
prompt_services(&p).expect("second answer is valid"),
ServiceSelection {
rest: false,
didcomm: false,
tsp: true
}
);
}
}