use std::path::PathBuf;
use std::sync::Arc;
use affinidi_tdk::secrets_resolver::secrets::Secret;
use dialoguer::{Confirm, Input, MultiSelect, Select};
use did_hosting_common::did::{DidDocumentOptions, build_did_document, create_log_entry};
use did_hosting_common::server::config::{
AuthConfig, FeaturesConfig, IdentityConfig, IdentityMode, LogConfig, LogFormat, ServerConfig,
StoreConfig, TransportSelection, VtaConfig,
};
use did_hosting_common::server::operator_messages::WebvhDaemonMessages;
use did_hosting_common::server::secret_store::{ServerSecrets, create_secret_store};
use did_hosting_common::server::setup_prompts;
use did_hosting_common::server::setup_recipe::split_origin_and_did_path;
use did_hosting_common::server::store::Store;
use did_hosting_common::server::store::{KS_ACL, KS_DIDS};
use did_hosting_common::server::vta_setup;
use serde::{Deserialize, Serialize};
use vta_sdk::client::VtaClient;
use vta_sdk::provision_client::{EphemeralSetupKey, OperatorMessages, ResolvedVta, resolve_vta};
use crate::config::{DaemonConfig, EnableConfig};
pub async fn run_setup_phase1(
out_path: &std::path::Path,
context_id: &str,
) -> Result<(), Box<dyn std::error::Error>> {
use std::io::stderr;
let messages = WebvhDaemonMessages;
let finalise = format!(
"did-hosting-daemon setup --setup-key-file {}",
out_path.display()
);
let mut writer = stderr();
vta_sdk::provision_client::driver::run_phase1_init(
&mut writer,
out_path,
context_id,
&messages,
Some(&finalise),
)
.await?;
Ok(())
}
pub async fn run_wizard(
config_path: Option<PathBuf>,
preloaded_setup_key_file: Option<PathBuf>,
) -> Result<(), Box<dyn std::error::Error>> {
eprintln!();
eprintln!(" DID Hosting Daemon — Setup Wizard");
eprintln!(" ============================");
eprintln!();
eprintln!(" The daemon runs control + server + witness + (optional) watcher");
eprintln!(" in a single process, sharing one DID identity and one listen");
eprintln!(" port. Setup provisions the DID via VTA and writes a unified");
eprintln!(" config.toml.");
eprintln!();
if preloaded_setup_key_file.is_none() {
match prompt_vta_mode()? {
VtaMode::Online => {}
VtaMode::OfflineStart => {
let (request, state) = prompt_offline_prepare_paths()?;
return run_setup_offline_prepare(config_path, request, state).await;
}
VtaMode::OfflineComplete => {
let (bundle, digest, state) = prompt_offline_complete_inputs()?;
return run_setup_offline_complete(bundle, digest, state).await;
}
VtaMode::SelfManaged => {
return run_self_managed_setup(config_path).await;
}
}
}
let default_path = config_path
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "config.toml".to_string());
let output_path: String = Input::new()
.with_prompt("Configuration file path")
.default(default_path)
.interact_text()?;
let output_path = PathBuf::from(&output_path);
if output_path.exists() {
let overwrite = Confirm::new()
.with_prompt(format!(
"{} already exists. Overwrite?",
output_path.display()
))
.default(false)
.interact()?;
if !overwrite {
eprintln!("Setup cancelled.");
return Ok(());
}
}
let (enable, mut features) = prompt_enable_and_features()?;
let messages: Arc<dyn OperatorMessages> = Arc::new(WebvhDaemonMessages);
let preloaded_setup_key = match preloaded_setup_key_file.as_deref() {
Some(path) => Some(EphemeralSetupKey::load_from(path)?),
None => None,
};
let result = run_online_provision(messages, preloaded_setup_key).await?;
let OnlineProvisionResult {
outcome,
mediator_did,
transport,
public_url,
did_path,
self_host,
} = result;
let (didcomm, tsp) = if mediator_did.is_some() {
transport.as_flags()
} else {
(false, false)
};
features.didcomm = didcomm;
features.tsp = tsp;
let host = setup_prompts::prompt_listen_host("0.0.0.0")?;
let port = setup_prompts::prompt_listen_port(8534)?;
let log_levels = ["info", "debug", "warn", "error", "trace"];
let log_level_idx = Select::new()
.with_prompt("Log level")
.items(log_levels)
.default(0)
.interact()?;
let log_level = log_levels[log_level_idx].to_string();
let log_format = setup_prompts::prompt_log_format()?;
let data_dir: String = Input::new()
.with_prompt("Data directory root")
.default("data/daemon".to_string())
.interact_text()?;
let store_path = PathBuf::from(&data_dir).join("store");
let witness_store_path = PathBuf::from(&data_dir).join("witness");
let secrets_config = did_hosting_common::server::secret_store::wizard::prompt_secrets_backend(
"did-hosting-daemon-secrets",
"webvh",
)
.await?;
let admin = prompt_admin_choice()?;
let jwt_signing_key = vta_setup::generate_ed25519_multibase();
eprintln!(" Generated JWT signing key.");
let config = DaemonConfig {
server: ServerConfig {
host: host.clone(),
port,
trusted_proxies: Vec::new(),
trusted_proxy_cidrs: Vec::new(),
},
log: LogConfig {
level: log_level,
format: log_format,
},
auth: AuthConfig::default(),
secrets: secrets_config,
server_did: Some(outcome.integration_did.clone()),
mediator_did: mediator_did.clone(),
public_url: Some(public_url.clone()),
did_hosting_url: Some(public_url.clone()),
store: StoreConfig {
data_dir: store_path,
..StoreConfig::default()
},
witness_store: StoreConfig {
data_dir: witness_store_path,
..StoreConfig::default()
},
limits: did_hosting_server::config::LimitsConfig::default(),
watchers: Vec::new(),
vta: VtaConfig {
url: outcome.vta_url.clone(),
did: Some(outcome.vta_did.clone()),
context_id: None,
},
watcher_sync: webvh_watcher::config::SyncConfig::default(),
registry: did_hosting_control::config::RegistryConfig::default(),
features,
identity: IdentityConfig::default(),
enable,
config_path: output_path.clone(),
hosting: did_hosting_common::server::config::HostingConfig::default(),
};
let log_entry = if self_host {
outcome.did_log_entry.as_deref()
} else {
None
};
finalize_daemon_setup(
&config,
&output_path,
ServerSecrets {
signing_key: outcome.integration_signing_key_mb,
key_agreement_key: outcome.integration_ka_key_mb,
jwt_signing_key,
vta_credential: Some(outcome.vta_credential_b64),
retired: Vec::new(),
},
log_entry,
&did_path,
admin,
)
.await?;
eprintln!();
eprintln!(" Setup complete!");
eprintln!();
eprintln!(" Daemon DID: {}", outcome.integration_did);
eprintln!(" Admin DID: {}", outcome.admin_did);
eprintln!();
eprintln!(" Start the daemon:");
eprintln!(" did-hosting-daemon --config {}", output_path.display());
eprintln!();
Ok(())
}
enum VtaMode {
Online,
OfflineStart,
OfflineComplete,
SelfManaged,
}
fn prompt_vta_mode() -> Result<VtaMode, Box<dyn std::error::Error>> {
let items = [
"Online — VTA reachable from this host",
"Offline — start a new sealed-bundle bootstrap (phase 1)",
"Offline — complete a pending sealed-bundle bootstrap (phase 2)",
"Self-managed (no VTA — daemon manages its own DID)",
];
let idx = Select::new()
.with_prompt("How will the daemon obtain its identity?")
.items(items)
.default(0)
.interact()?;
Ok(match idx {
0 => VtaMode::Online,
1 => VtaMode::OfflineStart,
2 => VtaMode::OfflineComplete,
_ => VtaMode::SelfManaged,
})
}
fn prompt_offline_prepare_paths() -> Result<(PathBuf, PathBuf), Box<dyn std::error::Error>> {
let request: String = Input::new()
.with_prompt("Bootstrap request file path")
.default("bootstrap-request.json".into())
.interact_text()?;
let state: String = Input::new()
.with_prompt("Pending state file path")
.default("setup-offline-state.toml".into())
.interact_text()?;
Ok((PathBuf::from(request), PathBuf::from(state)))
}
fn prompt_offline_complete_inputs() -> Result<(PathBuf, String, PathBuf), Box<dyn std::error::Error>>
{
let bundle: String = Input::new()
.with_prompt("ASCII-armored sealed bundle path")
.interact_text()?;
let digest: String = Input::new()
.with_prompt("Expected SHA-256 digest (lowercase hex)")
.interact_text()?;
let state: String = Input::new()
.with_prompt("Pending state file path (from phase 1)")
.default("setup-offline-state.toml".into())
.interact_text()?;
Ok((PathBuf::from(bundle), digest, PathBuf::from(state)))
}
#[derive(Default)]
struct WebvhTarget {
server_id: Option<String>,
domain: Option<String>,
path: Option<String>,
}
struct OnlineProvisionResult {
outcome: vta_setup::OnlineProvisionOutcome,
mediator_did: Option<String>,
transport: TransportSelection,
public_url: String,
did_path: String,
self_host: bool,
}
async fn run_online_provision(
messages: Arc<dyn OperatorMessages>,
preloaded_setup_key: Option<EphemeralSetupKey>,
) -> Result<OnlineProvisionResult, Box<dyn std::error::Error>> {
eprintln!();
eprintln!(" Authenticating to the VTA.");
eprintln!();
let vta_did =
setup_prompts::prompt_long_value("VTA DID (e.g. did:webvh:vta.example.com)", false)?;
let context_id: String = Input::new()
.with_prompt("Context ID")
.default("webvh".to_string())
.interact_text()?;
let setup_key = match preloaded_setup_key {
Some(key) => {
eprintln!();
eprintln!(" Using pre-loaded setup DID: {}", key.did);
key
}
None => {
let key = EphemeralSetupKey::generate()?;
eprintln!();
eprintln!(" Ephemeral setup DID: {}", key.did);
eprintln!();
eprintln!(" Run this on a workstation with PNM authenticated to the VTA");
eprintln!(" to create the context and grant the setup DID admin access:");
eprintln!();
eprintln!(
" {}",
messages.pnm_admin_command_hint(&context_id, &key.did)
);
eprintln!();
eprintln!(" If the context already exists, `contexts create` fails with a");
eprintln!(" 409 Conflict — grant the setup DID admin access on it instead:");
eprintln!();
eprintln!(
" pnm acl create --did {} --role admin \\\n --contexts {} --expires 1h",
key.did, context_id
);
eprintln!();
eprintln!(" The 1h expiry is a setup window — the entry is promoted to");
eprintln!(" permanent on first auth, which this wizard does for you.");
eprintln!();
let proceed = Confirm::new()
.with_prompt("Has the context been created?")
.default(true)
.interact()?;
if !proceed {
return Err("setup cancelled before VTA round-trip".into());
}
key
}
};
let resolved: Option<ResolvedVta> = match resolve_vta(&vta_did).await {
Ok(r) => Some(r),
Err(e) => {
eprintln!();
eprintln!(" Could not resolve the VTA DID ({e}).");
None
}
};
eprintln!();
let vta_mediator = resolved.as_ref().and_then(|r| r.mediator_did.clone());
let mediator_did = match vta_mediator {
Some(vm) => {
let options = [
format!("Use VTA's mediator ({vm})"),
"Enter a different mediator DID".to_string(),
"No mediator".to_string(),
];
let idx = Select::new()
.with_prompt("DIDComm mediator")
.items(&options)
.default(0)
.interact()?;
match idx {
0 => Some(vm),
1 => prompt_mediator_did()?,
_ => None,
}
}
None => prompt_mediator_did()?,
};
let transport = if mediator_did.is_some() {
setup_prompts::prompt_transport_selection()?
} else {
TransportSelection::Both
};
eprintln!();
eprintln!(" The public URL is where the daemon is reachable (API, UI,");
eprintln!(" WebAuthn RP). When self-hosting, the embedded server also");
eprintln!(" serves the daemon's DID document at <public-url>/<did-path>/did.jsonl.");
eprintln!();
let entered_url =
setup_prompts::prompt_long_value("Public URL (e.g. https://webvh.example.com)", false)?;
let (public_url, default_did_path) = split_origin_and_did_path(&entered_url);
let webvh = select_webvh_target(resolved.as_ref(), &setup_key).await?;
let self_host = webvh.server_id.is_none();
let did_path = match webvh.server_id {
None => prompt_did_path(&default_did_path)?,
Some(_) => webvh.path.clone().unwrap_or_else(|| ".well-known".into()),
};
let remote = webvh
.server_id
.as_deref()
.map(|server_id| vta_setup::WebvhRemoteTarget {
server_id,
domain: webvh.domain.as_deref(),
path: webvh.path.as_deref(),
});
let shape = vta_setup::WebvhDidShape::Hosted {
origin: &public_url,
did_path: &did_path,
mediator_did: mediator_did.as_deref(),
transport,
remote,
};
let ask = vta_setup::build_webvh_provision_ask(
&context_id,
&shape,
Some(&format!("did-hosting-daemon setup — {context_id}")),
);
eprintln!();
eprintln!(" Provisioning daemon DID via VTA...");
eprintln!();
let outcome = vta_setup::online_provision_flight(vta_did, setup_key, ask, messages).await?;
Ok(OnlineProvisionResult {
outcome,
mediator_did,
transport,
public_url,
did_path,
self_host,
})
}
async fn select_webvh_target(
resolved: Option<&ResolvedVta>,
setup_key: &EphemeralSetupKey,
) -> Result<WebvhTarget, Box<dyn std::error::Error>> {
eprintln!();
eprintln!(" DID publication");
eprintln!(" Choose where the daemon publishes its own did:webvh document:");
eprintln!(" - Serverless (self-host) — this daemon serves its own did.jsonl");
eprintln!(" at <public-url>/<did-path>/did.jsonl (the common default).");
eprintln!(" - A registered hosting server — the DID is published there");
eprintln!(" instead (redundancy / delegated hosting). The daemon's");
eprintln!(" public URL stays its own reachable URL regardless.");
eprintln!();
let client = match resolved {
Some(r) => match connect_setup_client(r, setup_key).await {
Ok(c) => Some(c),
Err(e) => {
eprintln!(" Could not reach the VTA to list hosting servers ({e});");
eprintln!(" defaulting to serverless (self-host).");
None
}
},
None => {
eprintln!(" The VTA DID didn't resolve, so a hosting-server picker isn't");
eprintln!(" available; defaulting to serverless (self-host).");
None
}
};
let Some(client) = client else {
return Ok(WebvhTarget::default());
};
let server_id = prompt_webvh_server(&client).await?;
let (domain, path) = match server_id.as_deref() {
Some(sid) => {
let domain = prompt_webvh_domain(&client, sid).await?;
let path = prompt_webvh_path(sid)?;
(domain, path)
}
None => (None, None),
};
Ok(WebvhTarget {
server_id,
domain,
path,
})
}
async fn connect_setup_client(
resolved: &ResolvedVta,
setup_key: &EphemeralSetupKey,
) -> Result<VtaClient, Box<dyn std::error::Error>> {
if let Some(rest_url) = resolved.rest_url.as_deref() {
let http = reqwest::Client::new();
let auth = vta_sdk::auth_light::challenge_response_light(
&http,
rest_url,
&setup_key.did,
setup_key.private_key_multibase(),
&resolved.vta_did,
)
.await
.map_err(|e| format!("VTA REST authentication failed: {e}"))?;
let client = VtaClient::new(rest_url);
client.set_token_async(auth.access_token).await;
return Ok(client);
}
if let Some(mediator_did) = resolved.mediator_did.as_deref() {
return VtaClient::connect_didcomm(
&setup_key.did,
setup_key.private_key_multibase(),
&resolved.vta_did,
mediator_did,
resolved.rest_url.clone(),
)
.await
.map_err(|e| format!("VTA DIDComm connection failed: {e}").into());
}
if resolved.tsp_mediator_did.is_some() {
return Err(
"VTA advertises only a TSP transport, which the hosting-server \
picker does not speak; register the server manually"
.into(),
);
}
Err("VTA advertises neither a REST nor a DIDComm transport".into())
}
async fn prompt_webvh_server(
client: &VtaClient,
) -> Result<Option<String>, Box<dyn std::error::Error>> {
let servers = match client.list_webvh_servers().await {
Ok(body) => body.servers,
Err(e) => {
eprintln!(" Could not list hosting servers ({e}); defaulting to serverless.");
return Ok(None);
}
};
if servers.is_empty() {
eprintln!(" No hosting servers are registered with this VTA — the daemon will");
eprintln!(" self-host its did.jsonl (serverless).");
return Ok(None);
}
let mut labels: Vec<String> = servers
.iter()
.map(|s| match s.label.as_deref() {
Some(label) if !label.is_empty() => format!("{} — {label} ({})", s.id, s.did),
_ => format!("{} ({})", s.id, s.did),
})
.collect();
labels.push("Serverless — self-host did.jsonl on this daemon".to_string());
let idx = Select::new()
.with_prompt("Where should the daemon DID be published?")
.items(&labels)
.default(0)
.interact()?;
if idx == servers.len() {
Ok(None)
} else {
Ok(Some(servers[idx].id.clone()))
}
}
async fn prompt_webvh_domain(
client: &VtaClient,
server_id: &str,
) -> Result<Option<String>, Box<dyn std::error::Error>> {
let domains = match client.list_webvh_server_domains(server_id).await {
Ok(d) => d,
Err(e) => {
eprintln!(
" Could not list hosting domains on `{server_id}` ({e}); using the \
server's default domain."
);
return Ok(None);
}
};
if domains.domains.len() < 2 {
return Ok(None);
}
let mut labels: Vec<String> = domains
.domains
.iter()
.map(|d| {
let default = if d.default_domain { " (default)" } else { "" };
let disabled = if d.status == "disabled" {
" [disabled]"
} else {
""
};
match d.label.as_deref() {
Some(l) if !l.is_empty() => format!("{}{default}{disabled} — {l}", d.name),
_ => format!("{}{default}{disabled}", d.name),
}
})
.collect();
labels.push("Use the server's default domain".to_string());
let default_idx = domains
.domains
.iter()
.position(|d| d.default_domain)
.unwrap_or(domains.domains.len());
let idx = Select::new()
.with_prompt(format!("Tenant domain on `{server_id}`"))
.items(&labels)
.default(default_idx)
.interact()?;
if idx == domains.domains.len() {
Ok(None)
} else {
Ok(Some(domains.domains[idx].name.clone()))
}
}
fn prompt_webvh_path(server_id: &str) -> Result<Option<String>, Box<dyn std::error::Error>> {
eprintln!();
eprintln!(" Optional path label under the hosting server `{server_id}`. It becomes");
eprintln!(" the trailing `<path>` of the daemon DID — e.g. `acme` yields a DID");
eprintln!(" ending `:acme`. Leave blank to let the server assign one.");
let raw: String = Input::new()
.with_prompt("WebVH path (blank → server-assigned)")
.default(String::new())
.allow_empty(true)
.interact_text()?;
let trimmed = raw.trim();
Ok(if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
})
}
async fn run_self_managed_setup(
config_path: Option<PathBuf>,
) -> Result<(), Box<dyn std::error::Error>> {
eprintln!();
eprintln!(" Self-managed mode: the daemon generates its own keys and");
eprintln!(" self-hosts a did:webvh identifier. No parent VTA is involved");
eprintln!(" in the daemon's own identity. External tenant VTAs may still");
eprintln!(" provision DIDs into this daemon over DIDComm at runtime.");
eprintln!();
eprintln!(" Daemon-only mode. Cannot be migrated to VTA-managed later.");
eprintln!();
let default_path = config_path
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "config.toml".to_string());
let output_path: String = Input::new()
.with_prompt("Configuration file path")
.default(default_path)
.interact_text()?;
let output_path = PathBuf::from(&output_path);
if output_path.exists() {
let overwrite = Confirm::new()
.with_prompt(format!(
"{} already exists. Overwrite?",
output_path.display()
))
.default(false)
.interact()?;
if !overwrite {
eprintln!("Setup cancelled.");
return Ok(());
}
}
let (enable, mut features) = prompt_enable_and_features()?;
eprintln!();
eprintln!(" The public URL is where the daemon is reachable. The daemon");
eprintln!(" hosts its own DID document at <public-url>/<did-path>/did.jsonl.");
eprintln!();
let entered_url =
setup_prompts::prompt_long_value("Public URL (e.g. https://webvh.example.com)", false)?;
let (public_url, default_did_path) = split_origin_and_did_path(&entered_url);
warn_if_insecure_public_url(&public_url);
let did_path = prompt_did_path(&default_did_path)?;
eprintln!();
eprintln!(" DIDComm mediator. Required if you want this daemon to:");
eprintln!(" - receive inbound DIDComm from external VTAs (tenant DID provisioning), or");
eprintln!(" - be registered as a DID hosting server with a VTA");
eprintln!(" (`vta webvh add-server` requires a DIDCommMessaging endpoint).");
eprintln!();
let mediator_did = match prompt_mediator_did()? {
Some(did) => Some(did),
None => {
eprintln!();
eprintln!(" No mediator configured. Without a mediator, the generated DID");
eprintln!(" document will not include a DIDCommMessaging service. This");
eprintln!(" daemon will not be usable as a VTA hosting server, and external");
eprintln!(" VTAs will not be able to send DIDComm messages to it.");
eprintln!();
let proceed = Confirm::new()
.with_prompt("Continue without a mediator?")
.default(false)
.interact()?;
if !proceed {
eprintln!();
eprintln!(" Setup cancelled. Re-run and supply a mediator DID when prompted.");
return Ok(());
}
None
}
};
if mediator_did.is_some() {
let (didcomm, tsp) = setup_prompts::prompt_transport_selection()?.as_flags();
features.didcomm = didcomm;
features.tsp = tsp;
}
let host = setup_prompts::prompt_listen_host("0.0.0.0")?;
let port = setup_prompts::prompt_listen_port(8534)?;
let log_levels = ["info", "debug", "warn", "error", "trace"];
let log_level_idx = Select::new()
.with_prompt("Log level")
.items(log_levels)
.default(0)
.interact()?;
let log_level = log_levels[log_level_idx].to_string();
let log_format = setup_prompts::prompt_log_format()?;
let data_dir: String = Input::new()
.with_prompt("Data directory root")
.default("data/daemon".to_string())
.interact_text()?;
let store_path = PathBuf::from(&data_dir).join("store");
let witness_store_path = PathBuf::from(&data_dir).join("witness");
let secrets_config = did_hosting_common::server::secret_store::wizard::prompt_secrets_backend(
"did-hosting-daemon-secrets",
"webvh",
)
.await?;
let signing = Secret::generate_ed25519(None, None);
let ka = Secret::generate_x25519(None, None)?;
let signing_pub_mb = signing.get_public_keymultibase()?;
let ka_pub_mb = ka.get_public_keymultibase()?;
let signing_priv_mb = signing.get_private_keymultibase()?;
let ka_priv_mb = ka.get_private_keymultibase()?;
let jwt_signing_key = vta_setup::generate_ed25519_multibase();
eprintln!();
eprintln!(" Generated Ed25519 signing key, X25519 key-agreement key, and JWT signing key.");
let host_encoded = did_hosting_common::did::encode_host(&public_url)
.map_err(|e| format!("failed to encode host from public URL: {e}"))?;
let doc = build_did_document(
&host_encoded,
&did_path,
&signing_pub_mb,
&DidDocumentOptions {
key_agreement_multibase: Some(&ka_pub_mb),
mediator_endpoint: if features.didcomm {
mediator_did.as_deref()
} else {
None
},
tsp_endpoint: if features.tsp {
mediator_did.as_deref()
} else {
None
},
},
);
let (_scid, jsonl) = create_log_entry(&doc, &signing)
.await
.map_err(|e| format!("failed to create DID log entry: {e}"))?;
let config = DaemonConfig {
server: ServerConfig {
host: host.clone(),
port,
trusted_proxies: Vec::new(),
trusted_proxy_cidrs: Vec::new(),
},
log: LogConfig {
level: log_level,
format: log_format,
},
auth: AuthConfig::default(),
secrets: secrets_config,
server_did: None,
mediator_did: mediator_did.clone(),
public_url: Some(public_url.clone()),
did_hosting_url: Some(public_url.clone()),
store: StoreConfig {
data_dir: store_path,
..StoreConfig::default()
},
witness_store: StoreConfig {
data_dir: witness_store_path,
..StoreConfig::default()
},
limits: did_hosting_server::config::LimitsConfig::default(),
watchers: Vec::new(),
vta: VtaConfig::default(),
watcher_sync: webvh_watcher::config::SyncConfig::default(),
registry: did_hosting_control::config::RegistryConfig::default(),
features,
identity: IdentityConfig {
mode: IdentityMode::SelfManaged,
..Default::default()
},
hosting: did_hosting_common::server::config::HostingConfig::default(),
enable,
config_path: output_path.clone(),
};
finalize_daemon_setup(
&config,
&output_path,
ServerSecrets {
signing_key: signing_priv_mb,
key_agreement_key: ka_priv_mb,
jwt_signing_key,
vta_credential: None,
retired: Vec::new(),
},
Some(&jsonl),
&did_path,
AdminChoice::Skip,
)
.await?;
eprintln!();
eprintln!(" Setup complete!");
eprintln!();
eprintln!(" Next steps:");
eprintln!();
eprintln!(" 1. Start the daemon:");
eprintln!(
" did-hosting-daemon --config {}",
output_path.display()
);
eprintln!();
eprintln!(" 2. Mint your first admin enrolment invite (replace");
eprintln!(" <ADMIN_DID> with the DID the admin will authenticate as):");
eprintln!(
" did-hosting-daemon invite --did <ADMIN_DID> --role admin \\\n --config {}",
output_path.display()
);
eprintln!();
eprintln!(" 3. Open the printed enrolment URL in a browser to bind a");
eprintln!(" passkey to that DID. Subsequent admin login uses the");
eprintln!(" passkey.");
eprintln!();
Ok(())
}
fn warn_if_insecure_public_url(url: &str) {
let lower = url.to_ascii_lowercase();
let is_http = lower.starts_with("http://");
let is_loopback = lower.contains("://localhost")
|| lower.contains("://127.0.0.1")
|| lower.contains("://[::1]");
if is_http || is_loopback {
eprintln!();
eprintln!(" ┌─ WARNING ──────────────────────────────────────────────");
if is_http {
eprintln!(" │ Public URL uses plaintext http://. DIDComm and DID");
eprintln!(" │ resolution from peers will be unauthenticated in");
eprintln!(" │ transit — only acceptable for development.");
}
if is_loopback {
eprintln!(" │ Public URL points at localhost / loopback. The DID");
eprintln!(" │ document will only resolve from this machine.");
}
eprintln!(" └────────────────────────────────────────────────────────");
eprintln!();
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(tag = "kind", content = "did", rename_all = "snake_case")]
enum AdminChoice {
Did(String),
Skip,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
struct PendingDaemonSetupState {
config_output: PathBuf,
enable: EnableConfig,
features: FeaturesConfig,
public_url: String,
did_path: String,
mediator_did: Option<String>,
host: String,
port: u16,
log_level: String,
log_format: LogFormat,
data_dir: String,
secrets: did_hosting_common::server::config::SecretsConfig,
admin: AdminChoice,
}
pub async fn run_setup_offline_prepare(
config_path: Option<PathBuf>,
request_out: PathBuf,
state_out: PathBuf,
) -> Result<(), Box<dyn std::error::Error>> {
eprintln!();
eprintln!(" DID Hosting Daemon — Offline Setup (step 1/2)");
eprintln!(" ========================================");
eprintln!();
let default_path = config_path
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "config.toml".to_string());
let output_path: String = Input::new()
.with_prompt("Configuration file path")
.default(default_path)
.interact_text()?;
let config_output = PathBuf::from(&output_path);
if config_output.exists() {
let overwrite = Confirm::new()
.with_prompt(format!(
"{} already exists. Overwrite?",
config_output.display()
))
.default(false)
.interact()?;
if !overwrite {
eprintln!("Setup cancelled.");
return Ok(());
}
}
let (enable, mut features) = prompt_enable_and_features()?;
let entered_url =
setup_prompts::prompt_long_value("Public URL (e.g. https://webvh.example.com)", false)?;
let (public_url, default_did_path) = split_origin_and_did_path(&entered_url);
let did_path = prompt_did_path(&default_did_path)?;
eprintln!();
eprintln!(" VTA context the integration will live in. Embedded in the");
eprintln!(" bootstrap request as `contextHint` so the VTA admin can run");
eprintln!(" `vta bootstrap provision-integration` without `--context`.");
eprintln!();
let context_id: String = Input::new()
.with_prompt("VTA context ID")
.default("webvh".to_string())
.interact_text()?;
eprintln!();
eprintln!(" In the offline flow we can't auto-discover the VTA's mediator.");
eprintln!();
let mediator_did = prompt_mediator_did()?;
if mediator_did.is_some() {
let (didcomm, tsp) = setup_prompts::prompt_transport_selection()?.as_flags();
features.didcomm = didcomm;
features.tsp = tsp;
} else {
features.didcomm = false;
features.tsp = false;
}
let host = setup_prompts::prompt_listen_host("0.0.0.0")?;
let port = setup_prompts::prompt_listen_port(8534)?;
let log_levels = ["info", "debug", "warn", "error", "trace"];
let log_level_idx = Select::new()
.with_prompt("Log level")
.items(log_levels)
.default(0)
.interact()?;
let log_level = log_levels[log_level_idx].to_string();
let log_format = setup_prompts::prompt_log_format()?;
let data_dir: String = Input::new()
.with_prompt("Data directory root")
.default("data/daemon".to_string())
.interact_text()?;
let secrets = did_hosting_common::server::secret_store::wizard::prompt_secrets_backend(
"did-hosting-daemon-secrets",
"webvh",
)
.await?;
let admin = prompt_admin_choice()?;
let shape = vta_setup::WebvhDidShape::Hosted {
origin: &public_url,
did_path: &did_path,
mediator_did: mediator_did.as_deref(),
transport: TransportSelection::from_flags(features.didcomm, features.tsp)
.unwrap_or(TransportSelection::Both),
remote: None,
};
let ask = vta_setup::build_webvh_provision_ask(
&context_id,
&shape,
Some(&format!("did-hosting-daemon setup — {context_id}")),
);
let info = vta_setup::write_offline_bootstrap_request(&request_out, &ask).await?;
let secret_store = create_secret_store(&secrets, &config_output)?;
secret_store.set_bootstrap_seed(&info.seed).await?;
let state = PendingDaemonSetupState {
config_output: config_output.clone(),
enable,
features,
public_url,
did_path,
mediator_did,
host,
port,
log_level,
log_format,
data_dir,
secrets,
admin,
};
let state_toml = toml::to_string_pretty(&state)?;
if let Some(parent) = state_out.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&state_out, &state_toml)?;
eprintln!();
eprintln!(" Offline setup step 1/2 complete.");
eprintln!();
eprintln!(" Request file: {}", info.request_path.display());
eprintln!(" State file: {}", state_out.display());
eprintln!(" Bootstrap seed: stored in the configured secrets backend");
eprintln!();
eprintln!(" Consumer DID: {}", info.client_did);
eprintln!(" Nonce: {}", info.nonce);
eprintln!();
eprintln!(" Next steps:");
eprintln!(
" 1. Ferry {} to your VTA admin.",
info.request_path.display()
);
eprintln!(" 2. Ask them to create the VTA context with this DID as admin,");
eprintln!(" via either:");
eprintln!(
" pnm contexts create --id {} --name \"DID Hosting daemon\" \\\n --admin-did {} --admin-expires 1h",
context_id, info.client_did
);
eprintln!(" or, on the VTA host directly:");
eprintln!(
" vta contexts create --id {} \\\n --admin-did {} --admin-expires 1h",
context_id, info.client_did
);
eprintln!(" If the context already exists, `contexts create` 409s — grant");
eprintln!(" the setup DID admin access on the existing context instead:");
eprintln!(
" pnm acl create --did {} --role admin \\\n --contexts {} --expires 1h",
info.client_did, context_id
);
eprintln!(" 3. Ask them to seal the response:");
eprintln!(
" vta bootstrap provision-integration --request <request-file> \\\n --out <bundle-file>"
);
eprintln!(" 4. They send back an ASCII-armored sealed bundle + SHA-256 digest.");
eprintln!(" 5. Run:");
eprintln!(
" did-hosting-daemon setup-offline-complete \\\n --bundle <bundle> --expect-digest <hex> --state {}",
state_out.display()
);
eprintln!();
Ok(())
}
pub async fn run_setup_offline_complete(
bundle_path: PathBuf,
expect_digest: String,
state_path: PathBuf,
) -> Result<(), Box<dyn std::error::Error>> {
eprintln!();
eprintln!(" DID Hosting Daemon — Offline Setup (step 2/2)");
eprintln!(" ========================================");
eprintln!();
let state_toml = std::fs::read_to_string(&state_path)?;
let state: PendingDaemonSetupState = toml::from_str(&state_toml)?;
let armor = std::fs::read_to_string(&bundle_path)?;
let secret_store = create_secret_store(&state.secrets, &state.config_output)?;
let seed = secret_store
.get_bootstrap_seed()
.await?
.ok_or("bootstrap seed missing from secret store — phase 1 may not have run")?;
let result = vta_setup::open_offline_bootstrap_response(&armor, &expect_digest, &seed)?;
eprintln!(" Sealed response opened.");
eprintln!(" DID: {}", result.did);
eprintln!(" VTA DID: {}", result.vta_did);
if let Some(ref url) = result.vta_url {
eprintln!(" VTA URL: {url}");
}
eprintln!();
let jwt_signing_key = vta_setup::generate_ed25519_multibase();
eprintln!(" Generated JWT signing key.");
let store_path = PathBuf::from(&state.data_dir).join("store");
let witness_store_path = PathBuf::from(&state.data_dir).join("witness");
let config = DaemonConfig {
server: ServerConfig {
host: state.host.clone(),
port: state.port,
trusted_proxies: Vec::new(),
trusted_proxy_cidrs: Vec::new(),
},
log: LogConfig {
level: state.log_level.clone(),
format: state.log_format.clone(),
},
auth: AuthConfig::default(),
secrets: state.secrets.clone(),
server_did: Some(result.did.clone()),
mediator_did: state.mediator_did.clone(),
public_url: Some(state.public_url.clone()),
did_hosting_url: Some(state.public_url.clone()),
store: StoreConfig {
data_dir: store_path,
..StoreConfig::default()
},
witness_store: StoreConfig {
data_dir: witness_store_path,
..StoreConfig::default()
},
limits: did_hosting_server::config::LimitsConfig::default(),
watchers: Vec::new(),
vta: VtaConfig {
url: result.vta_url.clone(),
did: Some(result.vta_did.clone()),
context_id: None,
},
watcher_sync: webvh_watcher::config::SyncConfig::default(),
registry: did_hosting_control::config::RegistryConfig::default(),
features: state.features.clone(),
identity: IdentityConfig::default(),
hosting: did_hosting_common::server::config::HostingConfig::default(),
enable: state.enable.clone(),
config_path: state.config_output.clone(),
};
finalize_daemon_setup(
&config,
&state.config_output,
ServerSecrets {
signing_key: result.signing_key_multibase,
key_agreement_key: result.key_agreement_multibase,
jwt_signing_key,
vta_credential: None,
retired: Vec::new(),
},
result.log_entry.as_deref(),
&state.did_path,
state.admin.clone(),
)
.await?;
let post_secret_store = create_secret_store(&config.secrets, &state.config_output)?;
if let Err(e) = post_secret_store.clear_bootstrap_seed().await {
eprintln!(" Warning: failed to clear bootstrap seed: {e}");
}
if let Err(e) = std::fs::remove_file(&state_path) {
eprintln!(" Warning: failed to remove state file: {e}");
}
eprintln!();
eprintln!(" Setup complete!");
eprintln!();
eprintln!(" Daemon DID: {}", result.did);
eprintln!();
eprintln!(" Start the daemon:");
eprintln!(
" did-hosting-daemon --config {}",
state.config_output.display()
);
eprintln!();
Ok(())
}
fn prompt_enable_and_features() -> Result<(EnableConfig, FeaturesConfig), Box<dyn std::error::Error>>
{
let service_items = &[
"control (management API + UI)",
"server (public DID hosting)",
"witness (witness proofs)",
"watcher (read-only mirror)",
];
let defaults = &[true, true, true, false];
let selected = MultiSelect::new()
.with_prompt("Which services should the daemon run? (Space to toggle, Enter to confirm)")
.items(service_items)
.defaults(defaults)
.interact()?;
let enable = EnableConfig {
control: selected.contains(&0),
server: selected.contains(&1),
witness: selected.contains(&2),
watcher: selected.contains(&3),
};
let features = FeaturesConfig {
rest_api: enable.control || enable.server,
didcomm: false,
..Default::default()
};
Ok((enable, features))
}
fn prompt_admin_choice() -> Result<AdminChoice, Box<dyn std::error::Error>> {
eprintln!();
eprintln!(" Admin ACL entry — the daemon rejects authenticated API calls");
eprintln!(" until at least one admin DID is enrolled.");
eprintln!();
let admin_options = &[
"Enter an existing DID (e.g. operator DID)",
"Generate a new did:key identity for the operator",
"Skip (add later with did-hosting-daemon add-acl)",
];
let admin_idx = Select::new()
.with_prompt("Admin ACL entry")
.items(admin_options)
.default(0)
.interact()?;
Ok(match admin_idx {
0 => {
let did: String = Input::new().with_prompt("Admin DID").interact_text()?;
AdminChoice::Did(did)
}
1 => {
let (did, sk) = vta_setup::generate_admin_did_key();
eprintln!(" Generated admin did:key: {did}");
eprintln!(" Private key (save this now — will not be re-shown): {sk}");
AdminChoice::Did(did)
}
_ => AdminChoice::Skip,
})
}
fn prompt_did_path(default: &str) -> Result<String, Box<dyn std::error::Error>> {
eprintln!();
eprintln!(" The DID path is where the daemon publishes its own DID document,");
eprintln!(" served at <public-url>/<did-path>/did.jsonl. Use `.well-known` for");
eprintln!(" the host's root DID, or a sub-path such as `dids/daemon`.");
eprintln!();
let did_path: String = Input::new()
.with_prompt("DID path on the server")
.default(default.to_string())
.interact_text()?;
let trimmed = did_path.trim().trim_matches('/');
Ok(if trimmed.is_empty() {
".well-known".to_string()
} else {
trimmed.to_string()
})
}
fn prompt_mediator_did() -> Result<Option<String>, Box<dyn std::error::Error>> {
let did = setup_prompts::prompt_long_value("Mediator DID (leave empty to skip)", true)?;
let trimmed = did.trim();
Ok(if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
})
}
async fn finalize_daemon_setup(
config: &DaemonConfig,
output_path: &std::path::Path,
secrets: ServerSecrets,
log_entry: Option<&str>,
did_path: &str,
admin: AdminChoice,
) -> Result<(), Box<dyn std::error::Error>> {
let toml_str = toml::to_string_pretty(config)?;
std::fs::write(output_path, &toml_str)?;
eprintln!(" Configuration written to {}", output_path.display());
let secret_store = create_secret_store(&config.secrets, output_path)?;
secret_store.set(&secrets).await?;
eprintln!(" Secrets stored in secret store.");
if let Some(log_entry) = log_entry {
eprintln!();
eprintln!(" Importing daemon DID into store at path '{did_path}'...");
let store = Store::open(&config.store).await?;
let dids_ks = store.keyspace(KS_DIDS)?;
match did_hosting_server::bootstrap::import_did_at_path(
&store, &dids_ks, did_path, log_entry, None,
)
.await
{
Ok(res) => {
eprintln!(" Daemon DID imported!");
eprintln!(" DID: {}", res.did_id);
eprintln!(" SCID: {}", res.scid);
did_hosting_server::setup::update_server_did_in_config(
&output_path.to_path_buf(),
&res.did_id,
)?;
eprintln!(" server_did updated in {}", output_path.display());
}
Err(e) => {
eprintln!(" Warning: failed to import daemon DID: {e}");
eprintln!(
" You can retry with `did-hosting-server bootstrap-did --path {did_path}` \
against this config's store path."
);
}
}
}
if let AdminChoice::Did(admin_did) = admin {
let store = Store::open(&config.store).await?;
let acl_ks = store.keyspace(KS_ACL)?;
let entry = did_hosting_common::server::acl::AclEntry {
did: admin_did.clone(),
role: did_hosting_common::server::acl::Role::Admin,
label: Some("Setup wizard admin".into()),
created_at: did_hosting_common::server::auth::session::now_epoch(),
max_total_size: None,
max_did_count: None,
domains: did_hosting_common::server::domain::DomainScope::All,
};
did_hosting_common::server::acl::store_acl_entry(&acl_ks, &entry).await?;
store.persist().await?;
eprintln!(" Admin ACL entry added for {admin_did}");
}
if let Some(vta_did) = config.vta.did.as_deref() {
let store = Store::open(&config.store).await?;
let acl_ks = store.keyspace(KS_ACL)?;
if did_hosting_common::server::acl::seed_provisioning_vta_acl(&acl_ks, vta_did).await? {
store.persist().await?;
eprintln!(" Provisioning-VTA ACL entry added for {vta_did}");
}
}
Ok(())
}