mod config;
mod setup;
mod setup_recipe;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use axum::Router;
use axum::http::StatusCode;
use axum::response::Response;
use axum::routing::get;
use clap::{Parser, Subcommand};
use tokio::sync::watch;
use tokio_util::sync::CancellationToken;
use tower_http::trace::{DefaultMakeSpan, DefaultOnResponse, TraceLayer};
use tracing::{Level, debug, error, info, warn};
use did_hosting_common::server::config::init_tracing;
use did_hosting_common::server::error::AppError;
use did_hosting_common::server::identity::ServiceIdentity;
use did_hosting_common::server::init;
use did_hosting_common::server::secret_store::ServerSecrets;
use did_hosting_common::server::stats_collector::StatsCollector;
use did_hosting_common::server::store::{KeyspaceHandle, Store};
use config::DaemonConfig;
use did_hosting_common::server::store::{
KS_ACL, KS_DIDS, KS_REGISTRY, KS_SESSIONS, KS_STATS, KS_TIMESERIES,
};
#[derive(Parser)]
#[command(
name = "did-hosting-daemon",
about = "DID Hosting Daemon — Unified Service",
version
)]
struct Cli {
#[arg(short, long, global = true)]
config: Option<PathBuf>,
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Subcommand)]
enum Command {
Setup {
#[arg(long, conflicts_with = "setup_key_file")]
setup_key_out: Option<PathBuf>,
#[arg(long, conflicts_with = "setup_key_out")]
setup_key_file: Option<PathBuf>,
#[arg(long, default_value = "webvh", requires = "setup_key_out")]
context: String,
#[arg(long, value_name = "FILE")]
from: Option<PathBuf>,
#[arg(long)]
force_reprovision: bool,
#[arg(long, requires = "from")]
non_interactive: bool,
},
Uninstall {
#[arg(long)]
yes: bool,
},
SetupOfflinePrepare {
#[arg(long, default_value = "bootstrap-request.json")]
request: PathBuf,
#[arg(long, default_value = "setup-offline-state.toml")]
state: PathBuf,
},
SetupOfflineComplete {
#[arg(long)]
bundle: PathBuf,
#[arg(long)]
expect_digest: String,
#[arg(long, default_value = "setup-offline-state.toml")]
state: PathBuf,
},
Health,
AddAcl {
#[arg(long)]
did: String,
#[arg(long, default_value = "owner")]
role: String,
#[arg(long)]
label: Option<String>,
},
ListAcl,
FetchMetrics {
#[arg(long)]
key: PathBuf,
#[arg(long)]
url: Option<String>,
},
IdentityList,
TspRelationshipList,
TspRelationshipReset {
#[arg(long)]
peer: String,
#[arg(long)]
our: Option<String>,
},
TspRelationshipDelete {
#[arg(long, required_unless_present = "all", conflicts_with = "all")]
peer: Option<String>,
#[arg(long, requires = "peer", conflicts_with = "all")]
our: Option<String>,
#[arg(long)]
all: bool,
#[arg(long, short = 'y')]
yes: bool,
},
IdentityRotateKeys {
#[arg(long, default_value = "ka")]
keys: String,
#[arg(long)]
ka_key: Option<String>,
#[arg(long)]
signing_key: Option<String>,
#[arg(long)]
grace: Option<String>,
},
IdentityRetireNow {
#[arg(long)]
generation: u64,
},
RemoveAcl {
#[arg(long)]
did: String,
},
Invite {
#[arg(long)]
did: String,
#[arg(long, default_value = "owner")]
role: String,
#[arg(long)]
ttl_hours: Option<u64>,
},
BootstrapDid {
#[arg(long, default_value = ".well-known")]
path: String,
#[arg(long)]
did_log: Option<PathBuf>,
#[arg(long)]
did_witness: Option<PathBuf>,
#[arg(long, requires = "witness_did")]
witness_url: Option<String>,
#[arg(long)]
witness_id: Option<String>,
#[arg(long)]
witness_did: Option<String>,
},
RecreateDid {
#[arg(long)]
path: String,
},
RecoverDid {
#[arg(long)]
path: String,
},
ListDids,
RemoveDid {
#[arg(long)]
path: String,
},
LoadDid {
#[arg(long)]
path: String,
#[arg(long)]
did_log: PathBuf,
#[arg(long)]
did_witness: Option<PathBuf>,
},
ImportSecrets {
#[arg(long, group = "source")]
vta_bundle: Option<String>,
#[arg(long, group = "source")]
signing_key: Option<String>,
#[arg(long)]
ka_key: Option<String>,
#[arg(long)]
jwt_key: Option<String>,
#[arg(long)]
vta_credential: Option<String>,
#[arg(long)]
force: bool,
},
Backup {
#[arg(short, long, default_value = "webvh-backup.json")]
output: String,
},
Restore {
#[arg(short, long)]
input: String,
},
MigrateFromWebvhConfig {
#[arg(long, value_name = "FILE")]
input: PathBuf,
#[arg(long, value_name = "FILE")]
output: Option<PathBuf>,
#[arg(long)]
force: bool,
},
}
#[tokio::main]
async fn main() {
let cli = Cli::parse();
print_banner();
match cli.command {
Some(Command::Setup {
setup_key_out,
setup_key_file,
context,
from,
force_reprovision,
non_interactive: _,
}) => {
if let Some(path) = setup_key_out {
if let Err(e) = setup::run_setup_phase1(&path, &context).await {
eprintln!("Setup error: {e}");
std::process::exit(1);
}
} else if let Some(recipe_path) = from {
match setup_recipe::run_from_recipe(&recipe_path, setup_key_file, force_reprovision)
.await
{
Ok(()) => {}
Err(e) => {
eprintln!("Setup error: {e}");
std::process::exit(setup_recipe::map_exit_code(&e));
}
}
} else if let Err(e) = setup::run_wizard(cli.config, setup_key_file).await {
eprintln!("Setup error: {e}");
std::process::exit(1);
}
}
Some(Command::Uninstall { yes }) => {
let config_path = cli
.config
.clone()
.unwrap_or_else(|| PathBuf::from("config.toml"));
if let Err(e) = setup_recipe::run_uninstall(&config_path, yes).await {
eprintln!("Uninstall error: {e}");
std::process::exit(1);
}
}
Some(Command::SetupOfflinePrepare { request, state }) => {
if let Err(e) = setup::run_setup_offline_prepare(cli.config, request, state).await {
eprintln!("Setup error: {e}");
std::process::exit(1);
}
}
Some(Command::SetupOfflineComplete {
bundle,
expect_digest,
state,
}) => {
if let Err(e) = setup::run_setup_offline_complete(bundle, expect_digest, state).await {
eprintln!("Setup error: {e}");
std::process::exit(1);
}
}
Some(Command::Health) => {
if let Err(e) = run_health(cli.config).await {
eprintln!("Health check error: {e}");
std::process::exit(1);
}
}
Some(Command::AddAcl { did, role, label }) => {
if let Err(e) = run_add_acl(cli.config, did, role, label).await {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
Some(Command::ListAcl) => {
if let Err(e) = run_list_acl(cli.config).await {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
Some(Command::FetchMetrics { key, url }) => {
if let Err(e) = run_fetch_metrics(cli.config, key, url).await {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
Some(Command::IdentityRotateKeys {
keys,
ka_key,
signing_key,
grace,
}) => {
if let Err(e) =
run_identity_rotate_keys(cli.config, keys, ka_key, signing_key, grace).await
{
eprintln!("Error: {e}");
std::process::exit(1);
}
}
Some(Command::TspRelationshipList) => {
if let Err(e) = run_tsp_relationship_list(cli.config).await {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
Some(Command::TspRelationshipReset { peer, our }) => {
if let Err(e) = run_tsp_relationship_reset(cli.config, peer, our).await {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
Some(Command::TspRelationshipDelete {
peer,
our,
all,
yes,
}) => {
if let Err(e) = run_tsp_relationship_delete(cli.config, peer, our, all, yes).await {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
Some(Command::IdentityList) => {
if let Err(e) = run_identity_list(cli.config).await {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
Some(Command::IdentityRetireNow { generation }) => {
if let Err(e) = run_identity_retire_now(cli.config, generation).await {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
Some(Command::RemoveAcl { did }) => {
if let Err(e) = run_remove_acl(cli.config, did).await {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
Some(Command::Invite {
did,
role,
ttl_hours,
}) => {
if let Err(e) = run_invite(cli.config, did, role, ttl_hours).await {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
Some(Command::BootstrapDid {
path,
did_log,
did_witness,
witness_url,
witness_id,
witness_did,
}) => {
if let Err(e) = run_bootstrap_did(
cli.config,
path,
did_log,
did_witness,
witness_url,
witness_id,
witness_did,
)
.await
{
eprintln!("Bootstrap error: {e}");
std::process::exit(1);
}
}
Some(Command::RecreateDid { path }) => {
if let Err(e) = run_recreate_did(cli.config, path).await {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
Some(Command::RecoverDid { path }) => {
if let Err(e) = run_recover_did(cli.config, path).await {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
Some(Command::ListDids) => {
if let Err(e) = run_list_dids(cli.config).await {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
Some(Command::RemoveDid { path }) => {
if let Err(e) = run_remove_did(cli.config, path).await {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
Some(Command::LoadDid {
path,
did_log,
did_witness,
}) => {
if let Err(e) = run_load_did(cli.config, path, did_log, did_witness).await {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
Some(Command::ImportSecrets {
vta_bundle,
signing_key,
ka_key,
jwt_key,
vta_credential,
force,
}) => {
if let Err(e) = run_import_secrets(
cli.config,
vta_bundle,
signing_key,
ka_key,
jwt_key,
vta_credential,
force,
)
.await
{
eprintln!("Error: {e}");
std::process::exit(1);
}
}
Some(Command::Backup { output }) => {
if let Err(e) = did_hosting_server::backup::run_backup(cli.config, output).await {
eprintln!("Backup error: {e}");
std::process::exit(1);
}
}
Some(Command::Restore { input }) => {
if let Err(e) = did_hosting_server::backup::run_restore(cli.config, input).await {
eprintln!("Restore error: {e}");
std::process::exit(1);
}
}
Some(Command::MigrateFromWebvhConfig {
input,
output,
force,
}) => {
if let Err(e) = run_migrate_from_webvh_config(&input, output.as_deref(), force) {
eprintln!("Config migration error: {e}");
std::process::exit(1);
}
}
None => run_daemon(cli.config).await,
}
}
fn run_migrate_from_webvh_config(
input: &std::path::Path,
output: Option<&std::path::Path>,
_force: bool,
) -> Result<(), String> {
if !input.exists() {
return Err(format!("input config not found: {}", input.display()));
}
let default_out;
let out: &std::path::Path = match output {
Some(p) => p,
None => {
let mut s = input.as_os_str().to_owned();
s.push(".migrated");
default_out = std::path::PathBuf::from(s);
default_out.as_path()
}
};
eprintln!(
"migrate-from-webvh-config: stub in v0.7.0\n \
input: {}\n \
output: {} (would be written)\n\n\
The rewriter is implemented in a follow-up release (see \
tasks/did-hosting-rollout-plan.md WS-7). For now, rename the \
crate refs / env vars manually:\n \
- WEBVH_* env vars → DID_HOSTING_*\n \
- webvh-server binary → did-hosting-server\n \
- webvh-control binary → did-hosting-control\n \
- webvh-daemon binary → did-hosting-daemon\n \
- (webvh-witness / webvh-watcher keep their names)",
input.display(),
out.display(),
);
Ok(())
}
async fn run_daemon(config_path: Option<PathBuf>) {
let mut config = match DaemonConfig::load(config_path) {
Ok(c) => c,
Err(e) => {
eprintln!("Error: {e}");
eprintln!();
eprintln!("Create a config.toml or specify one:");
eprintln!(" did-hosting-daemon --config <path>");
std::process::exit(1);
}
};
init_tracing(&config.log);
let secrets = load_secrets(&config).await;
let main_store = Store::open_with(&config.store, &config.fjall)
.await
.unwrap_or_else(|e| {
error!("failed to open main store: {e}");
std::process::exit(1);
});
let witness_store = Store::open_with(&config.witness_store, &config.fjall)
.await
.unwrap_or_else(|e| {
error!("failed to open witness store: {e}");
std::process::exit(1);
});
let dids_ks = main_store.keyspace(KS_DIDS).unwrap_or_else(|e| {
error!("failed to open dids keyspace: {e}");
std::process::exit(1);
});
let stats_ks = main_store.keyspace(KS_STATS).unwrap_or_else(|e| {
error!("failed to open stats keyspace: {e}");
std::process::exit(1);
});
let timeseries_ks = main_store.keyspace(KS_TIMESERIES).unwrap_or_else(|e| {
error!("failed to open timeseries keyspace: {e}");
std::process::exit(1);
});
match did_hosting_common::server::domain::seed_domains_first_boot(
&main_store,
&config.hosting.bootstrap_domains,
config.public_url.as_deref(),
)
.await
{
Ok(outcome) => {
info!(
tier = ?outcome.tier,
count = outcome.final_count,
default = ?outcome.default,
"first-boot domain seed"
);
}
Err(e) => {
error!("first-boot domain seed failed: {e}");
std::process::exit(1);
}
}
let assignment_now = did_hosting_common::server::auth::session::now_epoch();
match did_hosting_common::server::assignment_seed::seed_assignments_first_boot(
&main_store,
&config.hosting.bootstrap_domains,
config.public_url.as_deref(),
assignment_now,
)
.await
{
Ok(outcome) => {
info!(
tier = ?outcome.tier,
count = outcome.final_count,
"first-boot assignment seed"
);
}
Err(e) => {
error!("first-boot assignment seed failed: {e}");
std::process::exit(1);
}
}
{
let runner = did_hosting_common::server::migrations::MigrationRunner::new(
did_hosting_common::server::migrations::registry(),
);
match runner.run_pending(&main_store).await {
Ok(summary) => {
info!(
applied = ?summary.applied,
skipped = ?summary.skipped,
"migration runner complete"
);
}
Err(e) => {
error!("migration runner failed: {e}");
std::process::exit(1);
}
}
}
if config.enable.server {
match dids_ks.verify_integrity().await {
Ok(0) => debug!("store integrity check passed"),
Ok(n) => warn!(
corrupted = n,
"store integrity check found corrupted entries"
),
Err(e) => warn!(error = %e, "store integrity check failed"),
}
}
if config.enable.server {
let server_config = config.server_config();
let bootstrapped = did_hosting_server::server::auto_bootstrap_dids(
server_config,
&main_store,
&dids_ks,
&secrets,
)
.await;
if config.server_did.is_none() && bootstrapped.server_did.is_some() {
config.server_did = bootstrapped.server_did;
}
}
let stats_collector = {
use did_hosting_common::server::stats_collector::StatsAggregate;
let collector = StatsCollector::new();
let mut total_resolves = 0u64;
let mut total_updates = 0u64;
let mut last_resolved_at: Option<u64> = None;
let mut last_updated_at: Option<u64> = None;
if let Ok(raw) = stats_ks.prefix_iter_raw("stats:").await {
for (_key, value) in raw {
if let Ok(s) = serde_json::from_slice::<did_hosting_common::DidStats>(&value) {
total_resolves += s.total_resolves;
total_updates += s.total_updates;
last_resolved_at = match (last_resolved_at, s.last_resolved_at) {
(Some(a), Some(b)) => Some(a.max(b)),
(a, b) => a.or(b),
};
last_updated_at = match (last_updated_at, s.last_updated_at) {
(Some(a), Some(b)) => Some(a.max(b)),
(a, b) => a.or(b),
};
}
}
}
let total_dids = dids_ks
.prefix_iter_raw("did:")
.await
.map(|v| v.len())
.unwrap_or(0) as u64;
collector.seed_aggregate(&StatsAggregate {
total_dids,
total_resolves,
total_updates,
last_resolved_at,
last_updated_at,
});
info!(
total_dids,
total_resolves, total_updates, "stats collector seeded from store"
);
Arc::new(collector)
};
let identity = did_hosting_common::server::identity::load_identity(
config.server_did.as_deref(),
config.mediator_did.as_deref(),
did_hosting_common::server::identity::ProtocolSet {
didcomm: config.features.didcomm,
tsp: config.features.tsp,
},
&secrets,
&main_store,
)
.await;
let mut combined: Router = Router::new();
let mut server_state: Option<did_hosting_server::server::AppState> = None;
let mut enabled_services = Vec::new();
let http_client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.connect_timeout(Duration::from_secs(10))
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("failed to build HTTP client");
if config.enable.server {
match build_server(&config, &main_store, &stats_collector, identity.clone()).await {
Ok((router, state)) => {
combined = combined.merge(router);
server_state = Some(state);
enabled_services.push("server (/)");
}
Err(e) => {
error!("failed to initialize server: {e}");
std::process::exit(1);
}
}
}
if config.enable.witness {
match build_witness(&config, &witness_store, identity.clone()).await {
Ok(router) => {
combined = combined.nest("/witness", router);
enabled_services.push("witness (/witness)");
}
Err(e) => {
error!("failed to initialize witness: {e}");
std::process::exit(1);
}
}
}
if config.enable.watcher {
match build_watcher(&config, &main_store).await {
Ok(router) => {
combined = combined.nest("/watcher", router);
enabled_services.push("watcher (/watcher)");
}
Err(e) => {
error!("failed to initialize watcher: {e}");
std::process::exit(1);
}
}
}
let mut control_state: Option<did_hosting_control::server::AppState> = None;
if config.enable.control {
match build_control(
&config,
&secrets,
&main_store,
&stats_collector,
&http_client,
identity.clone(),
server_state.as_ref().map(|s| s.did_cache.clone()),
)
.await
{
Ok((router, state)) => {
combined = combined.merge(router);
control_state = Some(state);
enabled_services.push("control (/)");
}
Err(e) => {
error!("failed to initialize control plane: {e}");
std::process::exit(1);
}
}
}
combined = match server_state {
Some(ref state) => combined.fallback({
let state = state.clone();
move |request: axum::extract::Request| {
let state = state.clone();
async move { daemon_fallback(state, request).await }
}
}),
None => {
#[cfg(feature = "ui")]
{
combined.fallback(did_hosting_control::frontend::static_handler)
}
#[cfg(not(feature = "ui"))]
{
combined
}
}
};
let app = combined
.layer(
TraceLayer::new_for_http()
.make_span_with(DefaultMakeSpan::new().level(Level::DEBUG))
.on_response(
DefaultOnResponse::new()
.level(Level::DEBUG)
.latency_unit(tower_http::LatencyUnit::Millis),
),
)
.layer(did_hosting_common::server::public_resolution_cors())
.route("/health", get(daemon_health));
info!("--- daemon services ---");
for svc in &enabled_services {
info!(" {svc}");
}
let (storage_shutdown_tx, storage_shutdown_rx) = watch::channel(false);
let sessions_ks = main_store.keyspace(KS_SESSIONS).unwrap_or_else(|e| {
error!("failed to open sessions keyspace: {e}");
std::process::exit(1);
});
let storage_handle = tokio::spawn(run_daemon_storage_task(
DaemonStorageParams {
store: main_store.clone(),
sessions_ks,
dids_ks: dids_ks.clone(),
stats_ks,
timeseries_ks: timeseries_ks.clone(),
auth_config: config.auth.clone(),
has_auth: config.server_did.is_some(),
collector: stats_collector.clone(),
control_state: control_state.clone(),
},
storage_shutdown_rx,
));
let addr = format!("{}:{}", config.server.host, config.server.port);
let listener = tokio::net::TcpListener::bind(&addr)
.await
.unwrap_or_else(|e| {
error!("failed to bind {addr}: {e}");
std::process::exit(1);
});
info!("daemon listening on {addr}");
let (http_ready_tx, http_ready_rx) = tokio::sync::oneshot::channel::<()>();
let http_handle = tokio::spawn(async move {
let _ = http_ready_tx.send(());
axum::serve(
listener,
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.with_graceful_shutdown(init::shutdown_signal())
.await
.expect("axum serve failed");
});
let _ = http_ready_rx.await;
if let Some(ref state) = control_state
&& let Err(e) = did_hosting_control::identity_rotation::reload_now(state).await
{
warn!("failed to establish the service identity from its DID document: {e}");
}
let didcomm_shutdown = CancellationToken::new();
if config.features.didcomm || config.features.tsp {
if let Some(ref mut state) = control_state {
info!(
server_did = ?state.config.server_did,
mediator_did = ?state.config.mediator_did,
"starting control plane DIDComm service"
);
match did_hosting_control::server::start_didcomm_service(
state,
didcomm_shutdown.clone(),
)
.await
{
Ok(Some(svc)) => {
info!("DIDComm service started successfully");
let _ = state.didcomm_service.set(svc);
}
Ok(None) => {
warn!(
"DIDComm service returned None — check server_did and mediator_did config"
);
}
Err(e) => {
warn!("failed to start DIDComm service: {e}");
}
}
} else {
warn!("DIDComm enabled but control plane not enabled — skipping");
}
} else {
info!("DIDComm disabled in config");
}
let (outbox_shutdown_tx, outbox_shutdown_rx) = watch::channel(false);
let outbox_handle = if let Some(state) = control_state.as_ref() {
let outbox_state = state.clone();
let outbox_notify = state.outbox_notify.clone();
Some(tokio::spawn(async move {
did_hosting_control::outbox::run_outbox_loop(
outbox_state,
outbox_notify,
outbox_shutdown_rx,
)
.await;
}))
} else {
None
};
let _ = http_handle.await;
didcomm_shutdown.cancel();
info!("DIDComm service stopped");
let _ = outbox_shutdown_tx.send(true);
if let Some(handle) = outbox_handle {
match handle.await {
Ok(()) => info!("outbox worker stopped"),
Err(e) => warn!("outbox worker didn't shut down cleanly: {e}"),
}
}
let _ = storage_shutdown_tx.send(true);
match storage_handle.await {
Ok(()) => info!("storage task stopped"),
Err(e) => error!("storage task panicked: {e}"),
}
if let Err(e) = witness_store.persist().await {
error!("failed to persist witness store: {e}");
}
info!("daemon shut down");
}
struct DaemonStorageParams {
store: Store,
sessions_ks: KeyspaceHandle,
dids_ks: KeyspaceHandle,
stats_ks: KeyspaceHandle,
timeseries_ks: KeyspaceHandle,
auth_config: did_hosting_common::server::config::AuthConfig,
has_auth: bool,
collector: Arc<StatsCollector>,
control_state: Option<did_hosting_control::server::AppState>,
}
async fn run_daemon_storage_task(
params: DaemonStorageParams,
mut shutdown_rx: watch::Receiver<bool>,
) {
info!("storage task started");
let session_interval = Duration::from_secs(params.auth_config.session_cleanup_interval);
let did_ttl_seconds = params.auth_config.cleanup_ttl_minutes * 60;
let did_interval = Duration::from_secs(did_ttl_seconds.max(60));
let flush_interval = Duration::from_secs(10);
let mut session_timer = tokio::time::interval(session_interval);
let mut did_timer = tokio::time::interval(did_interval);
let mut flush_timer = tokio::time::interval(flush_interval);
let mut purge_timer =
tokio::time::interval(did_hosting_server::purge_sweep::DEFAULT_SWEEP_INTERVAL);
let mut identity_expiry_timer =
tokio::time::interval(did_hosting_control::identity_rotation::SWEEP_INTERVAL);
let mut identity_reload_timer =
tokio::time::interval(did_hosting_control::identity_rotation::RELOAD_INTERVAL);
session_timer.tick().await;
did_timer.tick().await;
flush_timer.tick().await;
purge_timer.tick().await;
identity_expiry_timer.tick().await;
identity_reload_timer.tick().await;
loop {
tokio::select! {
_ = session_timer.tick(), if params.has_auth => {
if let Err(e) = did_hosting_common::server::auth::session::cleanup_expired_sessions(
¶ms.sessions_ks,
params.auth_config.challenge_ttl,
).await {
warn!("session cleanup error: {e}");
}
}
_ = did_timer.tick() => {
match did_hosting_server::did_ops::cleanup_empty_dids(
¶ms.dids_ks,
did_ttl_seconds,
).await {
Ok(0) => {}
Ok(n) => {
info!(count = n, "cleaned up empty DID records");
for _ in 0..n {
params.collector.decrement_total_dids();
}
}
Err(e) => warn!("DID cleanup error: {e}"),
}
}
_ = flush_timer.tick() => {
if let Err(e) = did_hosting_control::server::flush_stats_to_store(
¶ms.collector,
¶ms.stats_ks,
¶ms.timeseries_ks,
¶ms.dids_ks,
¶ms.store,
).await {
warn!("stats flush error: {e}");
}
}
_ = purge_timer.tick() => {
let purged = did_hosting_server::purge_sweep::run_sweep_once(¶ms.store).await;
if purged > 0 {
info!(count = purged, "purge sweep tick completed");
}
}
_ = identity_expiry_timer.tick() => {
if let Some(state) = params.control_state.as_ref() {
did_hosting_control::identity_rotation::expire_due(state).await;
}
}
_ = identity_reload_timer.tick() => {
if let Some(state) = params.control_state.as_ref()
&& let Err(e) = did_hosting_control::identity_rotation::reload_now(state).await
{
debug!("identity backstop reload failed: {e}");
}
}
_ = shutdown_rx.changed() => {
info!("storage task shutting down");
break;
}
}
}
let _ = did_hosting_control::server::flush_stats_to_store(
¶ms.collector,
¶ms.stats_ks,
¶ms.timeseries_ks,
¶ms.dids_ks,
¶ms.store,
)
.await;
if let Err(e) = params.store.persist().await {
error!("failed to persist main store on shutdown: {e}");
} else {
info!("main store persisted");
}
}
type ServiceResult = Result<Router, AppError>;
async fn build_server(
config: &DaemonConfig,
store: &Store,
stats_collector: &Arc<StatsCollector>,
identity: Option<Arc<ServiceIdentity>>,
) -> Result<(Router, did_hosting_server::server::AppState), AppError> {
use did_hosting_server::server::AppState;
let server_config = config.server_config();
let mut state = AppState::new(store.clone(), server_config)?.with_identity(identity);
state.stats_collector = Some(stats_collector.clone());
let router = did_hosting_server::routes::router_public_only().with_state(state.clone());
info!("server service initialized (public-only, daemon mode)");
Ok((router, state))
}
async fn build_witness(
config: &DaemonConfig,
store: &Store,
identity: Option<Arc<ServiceIdentity>>,
) -> ServiceResult {
use webvh_witness::server::AppState;
use webvh_witness::signing::LocalSigner;
let state = AppState::new(
store.clone(),
config.witness_config(),
identity,
Arc::new(LocalSigner),
)?;
let router = webvh_witness::routes::router().with_state(state);
info!("witness service initialized");
Ok(router)
}
async fn build_watcher(config: &DaemonConfig, store: &Store) -> ServiceResult {
use webvh_watcher::server::AppState;
let state = AppState::new(store.clone(), config.watcher_config(), None)?;
let router = webvh_watcher::routes::router_public_only().with_state(state);
info!("watcher service initialized (resolution only, daemon mode)");
Ok(router)
}
fn control_verifier(
did_resolver: Option<&affinidi_did_resolver_cache_sdk::DIDCacheClient>,
) -> Option<Arc<did_hosting_common::server::trust_tasks::TransportBoundVerifier>> {
did_hosting_common::server::trust_tasks::build_verifier(did_resolver)
}
async fn build_control(
config: &DaemonConfig,
secrets: &ServerSecrets,
store: &Store,
stats_collector: &Arc<StatsCollector>,
http_client: &reqwest::Client,
identity: Option<Arc<ServiceIdentity>>,
did_cache: Option<Arc<did_hosting_server::cache::ContentCache>>,
) -> Result<(Router, did_hosting_control::server::AppState), AppError> {
use did_hosting_control::server::{AppState, CacheInvalidateFn};
let cache_invalidate: Option<Arc<CacheInvalidateFn>> = did_cache
.map(|cache| Arc::new(move |key: &str| cache.invalidate(key)) as Arc<CacheInvalidateFn>);
let control_config = config.control_config();
did_hosting_control::signing::require_signing_identity(
identity.as_deref(),
control_config.server_did.as_deref(),
)?;
let sessions_ks = store.keyspace(KS_SESSIONS)?;
let acl_ks = store.keyspace(KS_ACL)?;
let registry_ks = store.keyspace(KS_REGISTRY)?;
let dids_ks = store.keyspace(KS_DIDS)?;
let stats_ks = store.keyspace(KS_STATS)?;
let timeseries_ks = store.keyspace(KS_TIMESERIES)?;
let did_resolver = identity.as_ref().map(|i| i.did_resolver.clone());
let secrets_resolver = identity.as_ref().map(|i| i.secrets_resolver.clone());
let jwt_keys = init::init_jwt_keys(secrets);
let webauthn = control_config.public_url.as_ref().and_then(|url| {
match did_hosting_common::server::passkey::build_webauthn(url) {
Ok(w) => {
info!("WebAuthn (passkey) auth enabled for control plane");
Some(Arc::new(w))
}
Err(e) => {
warn!("WebAuthn initialization failed: {e} — passkey auth disabled");
None
}
}
});
let trust_tasks_verifier = control_verifier(did_resolver.as_ref());
let pending_challenges =
did_hosting_control::pending_challenges::PendingChallengeTracker::for_auth_config(
&control_config.auth,
);
let state = AppState {
store: store.clone(),
sessions_ks,
acl_ks,
registry_ks,
dids_ks,
config: Arc::new(control_config),
did_resolver,
secrets_resolver,
identity,
trust_tasks_verifier,
jwt_keys,
webauthn,
http_client: http_client.clone(),
didcomm_service: Arc::new(std::sync::OnceLock::new()),
stats_collector: stats_collector.clone(),
stats_ks,
timeseries_ks,
signing_key_bytes: init::decode_multibase_ed25519_key(&secrets.signing_key).ok(),
replay_cache: Arc::new(did_hosting_control::replay::ReplayCache::new()),
path_locks: did_hosting_control::path_locks::PathLocks::new(),
acl_locks: did_hosting_common::server::path_locks::PathLocks::new(),
pending_challenges: Arc::new(pending_challenges),
outbox_notify: Arc::new(tokio::sync::Notify::new()),
ip_rate_limiter: Arc::new(did_hosting_control::rate_limit::IpRateLimiter::new()),
redeem_rate_limiter: Arc::new(did_hosting_control::rate_limit::SourceRateLimiter::new()),
cache_invalidate,
oob: Default::default(),
large_document_budget: Arc::new(
did_hosting_common::server::trust_tasks::size::LargeDocumentBudget::new(),
),
};
state
.pending_challenges
.seed_from_sessions_or_warn(&state.sessions_ks)
.await;
did_hosting_control::server::seed_registry(&state).await;
did_hosting_control::sign_in_readiness::spawn(&state);
let router = did_hosting_control::routes::router_without_fallback().with_state(state.clone());
info!("control plane service initialized");
Ok((router, state))
}
async fn daemon_fallback(
state: did_hosting_server::server::AppState,
request: axum::extract::Request,
) -> Response {
let uri = request.uri().clone();
let did_resp =
did_hosting_server::routes::did_public::serve_public(axum::extract::State(state), request)
.await;
if did_resp.status() != StatusCode::NOT_FOUND {
return did_resp;
}
#[cfg(feature = "ui")]
{
did_hosting_control::frontend::static_handler(uri).await
}
#[cfg(not(feature = "ui"))]
{
let _ = uri;
StatusCode::NOT_FOUND.into_response()
}
}
async fn load_secrets(config: &DaemonConfig) -> ServerSecrets {
let secret_store = did_hosting_common::server::secret_store::create_secret_store(
&config.secrets,
&config.config_path,
)
.unwrap_or_else(|e| {
eprintln!("Error creating secret store: {e}");
std::process::exit(1);
});
match secret_store.get().await {
Ok(Some(s)) => {
info!("secrets loaded from secret store");
s
}
Ok(None) => {
eprintln!("Error: no secrets found — run service setup first");
std::process::exit(1);
}
Err(e) => {
eprintln!("Error loading secrets: {e}");
std::process::exit(1);
}
}
}
async fn run_add_acl(
config_path: Option<PathBuf>,
did: String,
role_str: String,
label: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
let config = DaemonConfig::load(config_path)?;
did_hosting_common::server::cli_acl::run_add_acl(
&config.store,
did,
role_str,
label,
None,
None,
)
.await
}
async fn run_list_acl(config_path: Option<PathBuf>) -> Result<(), Box<dyn std::error::Error>> {
let config = DaemonConfig::load(config_path)?;
did_hosting_common::server::cli_acl::run_list_acl(&config.store).await
}
async fn run_fetch_metrics(
config_path: Option<PathBuf>,
key_path: PathBuf,
url: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
let config = DaemonConfig::load(config_path)?;
let to = config
.server_did
.clone()
.ok_or("server_did is not configured; the daemon has not completed provisioning")?;
did_hosting_common::server::cli_metrics::fetch_metrics(
&to,
key_path.as_path(),
url,
&config.server.host,
config.server.port,
)
.await
}
async fn run_remove_acl(
config_path: Option<PathBuf>,
did: String,
) -> Result<(), Box<dyn std::error::Error>> {
let config = DaemonConfig::load(config_path)?;
did_hosting_common::server::cli_acl::run_remove_acl(&config.store, did).await
}
async fn run_invite(
config_path: Option<PathBuf>,
did: String,
role: String,
ttl_hours: Option<u64>,
) -> Result<(), Box<dyn std::error::Error>> {
let config = DaemonConfig::load(config_path)?;
let control_config = config.control_config();
let base_url = control_config
.public_url
.as_deref()
.ok_or("public_url must be set in config for enrollment invites")?;
let enrollment_ttl = match ttl_hours {
Some(hours) => hours * 3600,
None => control_config.auth.passkey_enrollment_ttl,
};
let store = Store::open(&control_config.store).await?;
let sessions_ks = store.keyspace(KS_SESSIONS)?;
did_hosting_common::server::passkey::run_cli_invite(
&sessions_ks,
base_url,
enrollment_ttl,
&did,
&role,
)
.await?;
Ok(())
}
async fn run_bootstrap_did(
config_path: Option<PathBuf>,
mnemonic: String,
did_log: Option<PathBuf>,
did_witness: Option<PathBuf>,
witness_url: Option<String>,
witness_id: Option<String>,
witness_did: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
use affinidi_tdk::secrets_resolver::secrets::Secret;
use did_hosting_server::bootstrap;
if did_witness.is_some() && did_log.is_none() {
return Err("--did-witness requires --did-log".into());
}
let config = DaemonConfig::load(config_path)?;
let server_config = config.server_config();
let public_url = server_config
.public_url
.as_deref()
.ok_or("public_url must be set in config for bootstrap")?;
let store = Store::open(&config.store).await?;
let dids_ks = store.keyspace(KS_DIDS)?;
let did_key = did_hosting_server::did_ops::did_key(&mnemonic);
if dids_ks.contains_key(did_key).await? {
eprintln!();
eprintln!(" DID at path '{mnemonic}' already exists.");
eprintln!(" No action taken.");
eprintln!();
return Ok(());
}
let result = if let Some(log_path) = did_log {
let jsonl = std::fs::read_to_string(&log_path)
.map_err(|e| format!("failed to read {}: {e}", log_path.display()))?;
let witness_content = match &did_witness {
Some(path) => Some(
std::fs::read_to_string(path)
.map_err(|e| format!("failed to read {}: {e}", path.display()))?,
),
None => None,
};
bootstrap::import_did_at_path(
&store,
&dids_ks,
&mnemonic,
&jsonl,
witness_content.as_deref(),
)
.await?
} else {
let secret_store = did_hosting_common::server::secret_store::create_secret_store(
&config.secrets,
&config.config_path,
)?;
let secrets = secret_store
.get()
.await?
.ok_or("no secrets found — run setup first")?;
let signing_secret = Secret::from_multibase(&secrets.signing_key, None)
.map_err(|e| format!("invalid signing_key: {e}"))?;
let ka_secret = Secret::from_multibase(&secrets.key_agreement_key, None).ok();
let mediator_uri = if let Some(ref vta_did) = config.mediator_did {
use did_hosting_common::server::didcomm_profile::resolve_mediator_did;
resolve_mediator_did(vta_did, None).await
} else {
None
};
let result = bootstrap::bootstrap_did(
&store,
&dids_ks,
&signing_secret,
ka_secret.as_ref(),
mediator_uri.as_deref(),
config.features.didcomm,
config.features.tsp,
public_url,
&mnemonic,
)
.await?;
if let (Some(w_url), Some(w_id), Some(w_did)) = (witness_url, witness_id, witness_did) {
eprintln!(" Requesting witness proof...");
eprintln!(" NOTE: the DID must already be served (by a running server) for the");
eprintln!(" witness to resolve it and verify the request.");
match bootstrap::request_witness_proof(&w_url, &w_did, &w_id, &result, &signing_secret)
.await
{
Ok(witness_file) => {
dids_ks
.insert_raw(
did_hosting_server::did_ops::content_witness_key(&mnemonic),
witness_file.into_bytes(),
)
.await?;
eprintln!(" Witness proof stored.");
}
Err(e) => {
eprintln!(" Warning: witness proof request failed: {e}");
eprintln!(" The DID was created but has no witness proof.");
}
}
}
result
};
store.persist().await?;
let url_path = if mnemonic == ".well-known" {
".well-known/did.jsonl".to_string()
} else {
format!("{mnemonic}/did.jsonl")
};
eprintln!();
if mnemonic == ".well-known" {
eprintln!(" Root DID bootstrapped!");
} else {
eprintln!(" DID bootstrapped at path '{mnemonic}'!");
}
eprintln!();
eprintln!(" DID: {}", result.did_id);
eprintln!(" SCID: {}", result.scid);
eprintln!(" JSONL: {public_url}/{url_path}");
eprintln!();
Ok(())
}
async fn run_recreate_did(
config_path: Option<PathBuf>,
mnemonic: String,
) -> Result<(), Box<dyn std::error::Error>> {
use affinidi_tdk::secrets_resolver::secrets::Secret;
use did_hosting_server::bootstrap;
let config = DaemonConfig::load(config_path)?;
let config_file = config.config_path.clone();
let server_config = config.server_config();
let public_url = server_config
.public_url
.as_deref()
.ok_or("public_url must be set in config")?;
let store = Store::open(&config.store).await?;
let dids_ks = store.keyspace(KS_DIDS)?;
let did_key = did_hosting_server::did_ops::did_key(&mnemonic);
if let Some(existing) = dids_ks
.get::<did_hosting_common::did_ops::DidRecord>(did_key.clone())
.await?
{
dids_ks.remove(did_key).await?;
dids_ks
.remove(did_hosting_server::did_ops::content_log_key(&mnemonic))
.await?;
dids_ks
.remove(did_hosting_server::did_ops::content_witness_key(&mnemonic))
.await?;
dids_ks
.remove(did_hosting_server::did_ops::owner_key(
&existing.owner,
&mnemonic,
))
.await?;
eprintln!(" Removed existing DID at path '{mnemonic}'");
}
let secret_store = did_hosting_common::server::secret_store::create_secret_store(
&config.secrets,
&config.config_path,
)?;
let secrets = secret_store
.get()
.await?
.ok_or("no secrets found — run setup first")?;
let signing_secret = Secret::from_multibase(&secrets.signing_key, None)
.map_err(|e| format!("invalid signing_key: {e}"))?;
let ka_secret = Secret::from_multibase(&secrets.key_agreement_key, None).ok();
let mediator_uri = if let Some(ref vta_did) = config.mediator_did {
use did_hosting_common::server::didcomm_profile::resolve_mediator_did;
resolve_mediator_did(vta_did, None).await
} else {
None
};
let result = bootstrap::bootstrap_did(
&store,
&dids_ks,
&signing_secret,
ka_secret.as_ref(),
mediator_uri.as_deref(),
config.features.didcomm,
config.features.tsp,
public_url,
&mnemonic,
)
.await?;
store.persist().await?;
did_hosting_server::setup::update_server_did_in_config(&config_file, &result.did_id)?;
eprintln!();
eprintln!(" DID recreated at path '{mnemonic}'!");
eprintln!();
eprintln!(" DID: {}", result.did_id);
eprintln!(" SCID: {}", result.scid);
eprintln!(" config.toml updated with new server_did.");
eprintln!();
Ok(())
}
async fn run_recover_did(
config_path: Option<PathBuf>,
mnemonic: String,
) -> Result<(), Box<dyn std::error::Error>> {
use did_hosting_common::did_ops::DidRecord;
let config = DaemonConfig::load(config_path)?;
let store = Store::open(&config.store).await?;
let dids_ks = store.keyspace(KS_DIDS)?;
let did_key = did_hosting_common::did_ops::did_key(&mnemonic);
let mut record: DidRecord = dids_ks
.get(did_key.as_str())
.await?
.ok_or(format!("DID not found at path '{mnemonic}'"))?;
if record.deleted_at.is_none() {
eprintln!(" DID at path '{mnemonic}' is not deleted.");
return Ok(());
}
record.deleted_at = None;
dids_ks.insert(did_key.as_str(), &record).await?;
store.persist().await?;
eprintln!();
eprintln!(" DID recovered at path '{mnemonic}'!");
if let Some(ref did_id) = record.did_id {
eprintln!(" DID: {did_id}");
}
eprintln!();
Ok(())
}
async fn run_load_did(
config_path: Option<PathBuf>,
path: String,
did_log: PathBuf,
did_witness: Option<PathBuf>,
) -> Result<(), Box<dyn std::error::Error>> {
let config = DaemonConfig::load(config_path)?;
let store = Store::open(&config.store).await?;
let dids_ks = store.keyspace(KS_DIDS)?;
let jsonl = std::fs::read_to_string(&did_log)
.map_err(|e| format!("failed to read {}: {e}", did_log.display()))?;
let witness_content = match &did_witness {
Some(wp) => Some(
std::fs::read_to_string(wp)
.map_err(|e| format!("failed to read {}: {e}", wp.display()))?,
),
None => None,
};
let result = did_hosting_server::bootstrap::import_did_at_path(
&store,
&dids_ks,
&path,
&jsonl,
witness_content.as_deref(),
)
.await?;
store.persist().await?;
eprintln!();
eprintln!(" DID loaded at path '{path}'!");
eprintln!();
eprintln!(" DID: {}", result.did_id);
eprintln!(" SCID: {}", result.scid);
eprintln!(" Path: {path}/did.jsonl");
eprintln!();
Ok(())
}
async fn run_import_secrets(
config_path: Option<PathBuf>,
vta_bundle: Option<String>,
signing_key: Option<String>,
ka_key: Option<String>,
jwt_key: Option<String>,
vta_credential: Option<String>,
force: bool,
) -> Result<(), Box<dyn std::error::Error>> {
use affinidi_tdk::secrets_resolver::secrets::Secret;
use did_hosting_common::server::vta_setup::generate_ed25519_multibase;
use vta_sdk::did_secrets::DidSecretsBundle;
use vta_sdk::keys::KeyType;
let config = DaemonConfig::load(config_path)?;
let secret_store = did_hosting_common::server::secret_store::create_secret_store(
&config.secrets,
&config.config_path,
)?;
if !force && let Ok(Some(_)) = secret_store.get().await {
return Err("secrets already exist — use --force to overwrite".into());
}
let (resolved_signing, resolved_ka, resolved_vta_cred) =
if let Some(ref bundle_str) = vta_bundle {
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD as BASE64;
let bundle_json = BASE64
.decode(bundle_str.as_bytes())
.map_err(|e| format!("failed to decode VTA secrets bundle base64: {e}"))?;
let bundle: DidSecretsBundle = serde_json::from_slice(&bundle_json)
.map_err(|e| format!("failed to decode VTA secrets bundle: {e}"))?;
let mut signing = None;
let mut ka = None;
for entry in &bundle.secrets {
match entry.key_type {
KeyType::Ed25519 if signing.is_none() => {
signing = Some(entry.private_key_multibase.clone());
}
KeyType::X25519 if ka.is_none() => {
ka = Some(entry.private_key_multibase.clone());
}
_ => {}
}
}
let signing = signing.ok_or("VTA bundle contains no Ed25519 signing key")?;
let ka = ka.ok_or("VTA bundle contains no X25519 key agreement key")?;
eprintln!(" VTA bundle decoded for DID: {}", bundle.did);
eprintln!(" Found {} secret(s)", bundle.secrets.len());
(signing, ka, vta_credential)
} else if let Some(signing) = signing_key {
let ka = ka_key.ok_or("--ka-key is required when using --signing-key")?;
(signing, ka, vta_credential)
} else {
return Err("provide either --vta-bundle or --signing-key + --ka-key".into());
};
Secret::from_multibase(&resolved_signing, None)
.map_err(|e| format!("invalid signing key: {e}"))?;
Secret::from_multibase(&resolved_ka, None)
.map_err(|e| format!("invalid key agreement key: {e}"))?;
let resolved_jwt = match jwt_key {
Some(key) => {
Secret::from_multibase(&key, None)
.map_err(|e| format!("invalid JWT signing key: {e}"))?;
key
}
None => {
eprintln!(" Generated JWT signing key.");
generate_ed25519_multibase()
}
};
let retired = match secret_store.get().await {
Ok(Some(existing)) => existing.retired,
_ => Vec::new(),
};
if !retired.is_empty() {
eprintln!(
" Preserving {} retired key(s) — superseded generations still inside their grace period.",
retired.len()
);
}
let server_secrets = ServerSecrets {
signing_key: resolved_signing,
key_agreement_key: resolved_ka,
jwt_signing_key: resolved_jwt,
vta_credential: resolved_vta_cred,
retired,
};
secret_store.set(&server_secrets).await?;
eprintln!();
eprintln!(" Secrets imported successfully!");
eprintln!();
Ok(())
}
async fn run_health(config_path: Option<PathBuf>) -> Result<(), Box<dyn std::error::Error>> {
use did_hosting_common::server::health;
health::header("did-hosting-daemon", env!("CARGO_PKG_VERSION"));
let config = match DaemonConfig::load(config_path) {
Ok(c) => {
health::section("Configuration");
health::check_config_loaded(&c.config_path);
health::check_value("server_did", &c.server_did);
health::check_value("public_url", &c.public_url);
health::check_value("did_hosting_url", &c.did_hosting_url);
health::check_value("mediator_did", &c.mediator_did);
Some(c)
}
Err(e) => {
health::section("Configuration");
health::fail(&format!("Config load failed: {e}"));
None
}
};
health::section("Compile Features");
health::print_feature("store-fjall", cfg!(feature = "store-fjall"));
health::print_feature("keyring", cfg!(feature = "keyring"));
health::print_feature("ui", cfg!(feature = "ui"));
let config = match config {
Some(c) => c,
None => {
eprintln!();
return Ok(());
}
};
health::section("Enabled Services");
health::print_feature("server", config.enable.server);
health::print_feature("witness", config.enable.witness);
health::print_feature("watcher", config.enable.watcher);
health::print_feature("control", config.enable.control);
health::print_feature("didcomm", config.features.didcomm);
health::print_feature("tsp", config.features.tsp);
health::section("Secrets");
health::check_secrets(&config.secrets, &config.config_path).await;
if config.enable.server {
health::section("Store (server)");
let store = health::check_store(&config.store).await;
if let Some(ref store) = store
&& let Ok(dids_ks) = store.keyspace(KS_DIDS)
{
health::section("Root DID (.well-known)");
match did_hosting_server::bootstrap::root_did_exists(&dids_ks).await {
Ok(true) => {
health::pass("Root DID exists");
match dids_ks
.get::<did_hosting_server::did_ops::DidRecord>(
did_hosting_server::did_ops::did_key(".well-known"),
)
.await
{
Ok(Some(record)) => {
if let Some(ref did_id) = record.did_id {
health::info_msg(&format!("DID: {did_id}"));
}
health::info_msg(&format!("Version count: {}", record.version_count));
}
Ok(None) => {}
Err(e) => health::warn_msg(&format!("Could not read DID record: {e}")),
}
}
Ok(false) => health::skip("Root DID not yet bootstrapped"),
Err(e) => health::fail(&format!("Root DID check failed: {e}")),
}
}
}
if config.enable.witness {
health::section("Store (witness)");
health::check_store(&config.witness_store).await;
}
if config.enable.watcher {
health::section("Store (watcher — shared with server)");
health::check_store(&config.store).await;
}
if config.enable.control {
health::section("Store (control — shared with server)");
health::check_store(&config.store).await;
}
if let Some(ref did) = config.server_did {
health::section("DID Resolution");
health::check_did_resolution("Server DID resolves", did).await;
}
if let Some(ref did) = config.mediator_did {
health::section("Mediator DID Resolution");
health::check_did_resolution("Mediator DID resolves", did).await;
}
eprintln!();
Ok(())
}
async fn daemon_health() -> axum::Json<serde_json::Value> {
axum::Json(serde_json::json!({
"status": "ok",
"service": "did-hosting-daemon",
}))
}
fn print_banner() {
let cyan = "\x1b[36m";
let magenta = "\x1b[35m";
let yellow = "\x1b[33m";
let dim = "\x1b[2m";
let reset = "\x1b[0m";
eprintln!(
r#"
{cyan}██████╗ {magenta} █████╗ {yellow}███████╗{cyan}███╗ ███╗{magenta} ██████╗ {yellow}███╗ ██╗{reset}
{cyan}██╔══██╗{magenta}██╔══██╗{yellow}██╔════╝{cyan}████╗ ████║{magenta}██╔═══██╗{yellow}████╗ ██║{reset}
{cyan}██║ ██║{magenta}███████║{yellow}█████╗ {cyan}██╔████╔██║{magenta}██║ ██║{yellow}██╔██╗ ██║{reset}
{cyan}██║ ██║{magenta}██╔══██║{yellow}██╔══╝ {cyan}██║╚██╔╝██║{magenta}██║ ██║{yellow}██║╚██╗██║{reset}
{cyan}██████╔╝{magenta}██║ ██║{yellow}███████╗{cyan}██║ ╚═╝ ██║{magenta}╚██████╔╝{yellow}██║ ╚████║{reset}
{cyan}╚═════╝ {magenta}╚═╝ ╚═╝{yellow}╚══════╝{cyan}╚═╝ ╚═╝{magenta} ╚═════╝ {yellow}╚═╝ ╚═══╝{reset}
{dim} DID Hosting Daemon v{version}{reset}
"#,
version = env!("CARGO_PKG_VERSION"),
);
}
async fn run_list_dids(
config_path: Option<PathBuf>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use did_hosting_common::did_ops::DidRecord;
let config = DaemonConfig::load(config_path)?;
let store = did_hosting_common::server::store::Store::open(&config.store).await?;
let dids_ks = store.keyspace(KS_DIDS)?;
let raw = dids_ks.prefix_iter_raw("did:").await?;
if raw.is_empty() {
eprintln!(" No DIDs in store.");
return Ok(());
}
eprintln!(" {:<25} {:<15} {:<60}", "PATH", "VERSIONS", "DID ID");
eprintln!(" {}", "-".repeat(100));
for (_key, value) in &raw {
if let Ok(record) = serde_json::from_slice::<DidRecord>(value) {
let did_id = record.did_id.as_deref().unwrap_or("(unpublished)");
let deleted = if record.deleted_at.is_some() {
" [deleted]"
} else {
""
};
let disabled = if record.disabled { " [disabled]" } else { "" };
eprintln!(
" {:<25} {:<15} {}{}{}",
record.mnemonic, record.version_count, did_id, deleted, disabled
);
}
}
eprintln!();
eprintln!(" Total: {} DIDs", raw.len());
Ok(())
}
async fn run_remove_did(
config_path: Option<PathBuf>,
path: String,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use did_hosting_common::did_ops::{
DidRecord, content_log_key, content_witness_key, did_key, owner_key,
};
let config = DaemonConfig::load(config_path)?;
let store = did_hosting_common::server::store::Store::open(&config.store).await?;
let dids_ks = store.keyspace(KS_DIDS)?;
let record: Option<DidRecord> = dids_ks.get(did_key(&path)).await?;
let record = match record {
Some(r) => r,
None => {
eprintln!(" DID not found at path '{path}'");
std::process::exit(1);
}
};
let did_id = record.did_id.as_deref().unwrap_or("(unpublished)");
eprintln!(" Removing DID at path '{path}'");
eprintln!(" DID ID: {did_id}");
eprintln!(" Owner: {}", record.owner);
let mut batch = store.batch();
batch.remove(&dids_ks, did_key(&path));
batch.remove(&dids_ks, content_log_key(&path));
batch.remove(&dids_ks, content_witness_key(&path));
batch.remove(&dids_ks, owner_key(&record.owner, &path));
batch.commit().await?;
eprintln!(" DID removed.");
Ok(())
}
async fn run_tsp_relationship_list(
config_path: Option<PathBuf>,
) -> Result<(), Box<dyn std::error::Error>> {
let config = DaemonConfig::load(config_path)?;
did_hosting_common::server::cli_tsp::run_list(&config.store).await
}
async fn run_tsp_relationship_reset(
config_path: Option<PathBuf>,
peer: String,
our: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
use did_hosting_common::server::cli_tsp::{Target, run_reset};
let config = DaemonConfig::load(config_path)?;
run_reset(&config.store, Target::Peer { peer, our }).await
}
async fn run_tsp_relationship_delete(
config_path: Option<PathBuf>,
peer: Option<String>,
our: Option<String>,
all: bool,
yes: bool,
) -> Result<(), Box<dyn std::error::Error>> {
use did_hosting_common::server::cli_tsp::{Target, run_delete};
let config = DaemonConfig::load(config_path)?;
let target = match peer {
Some(peer) if !all => Target::Peer { peer, our },
_ => Target::All,
};
run_delete(&config.store, target, yes).await
}
async fn run_identity_list(config_path: Option<PathBuf>) -> Result<(), Box<dyn std::error::Error>> {
let config = DaemonConfig::load(config_path)?;
did_hosting_common::server::cli_identity::run_list_generations(&config.store).await
}
async fn run_identity_retire_now(
config_path: Option<PathBuf>,
generation: u64,
) -> Result<(), Box<dyn std::error::Error>> {
let config = DaemonConfig::load(config_path)?;
did_hosting_common::server::cli_identity::run_retire_generation(
&config.store,
&config.secrets,
&config.config_path,
generation,
)
.await
}
async fn run_identity_rotate_keys(
config_path: Option<PathBuf>,
keys: String,
ka_key: Option<String>,
signing_key: Option<String>,
grace: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
use did_hosting_common::server::identity_rotate::{RotateKeys, rotate_keys};
use did_hosting_common::server::secret_store::create_secret_store;
use did_hosting_common::server::store::Store;
let config = DaemonConfig::load(config_path)?;
let server_did = config
.server_did
.as_deref()
.ok_or("server_did is not set — this service has no identity to rotate")?;
let which = RotateKeys::parse(&keys)?;
let grace_secs = match grace.as_deref() {
Some(s) => did_hosting_common::server::pending_purge::parse_grace_string(s)
.map_err(|e| format!("invalid --grace: {e}"))?,
None => config.identity.rotation_grace_secs(),
};
let store = Store::open(&config.store).await?;
let secret_store = create_secret_store(&config.secrets, &config.config_path)?;
let report = rotate_keys(
&store,
secret_store.as_ref(),
server_did,
which,
ka_key.as_deref(),
signing_key.as_deref(),
grace_secs,
)
.await?;
eprintln!();
eprintln!(" Rotated keys for {}", report.did);
if report.which != RotateKeys::Signing {
eprintln!(" key agreement (new) : {}", report.new_ka_kid);
if grace_secs > 0 {
eprintln!(
" key agreement (old) : {} — honoured for {}m",
report.retired_ka_kid,
grace_secs / 60
);
} else {
eprintln!(
" key agreement (old) : {} — RETIRED IMMEDIATELY, no grace period",
report.retired_ka_kid
);
}
}
if report.which != RotateKeys::KeyAgreement {
eprintln!(" signing (new) : {}", report.new_signing_kid);
eprintln!(" signing (old) : {}", report.retired_signing_kid);
eprintln!();
eprintln!(" The old signing key's authority to publish DID updates is REVOKED as of");
eprintln!(" this log entry — it can no longer sign an update the chain will accept.");
eprintln!(" There is no grace period for a signing key and there cannot be: peers");
eprintln!(" holding a cached DID document will reject signatures made with the new");
eprintln!(" key until they re-resolve.");
}
eprintln!();
eprintln!(
" generation {} -> {}",
report.retired_generation, report.new_generation
);
eprintln!(" did.jsonl now has {} entries", report.version_count);
eprintln!();
if grace_secs > 0 && report.which != RotateKeys::Signing {
eprintln!(" Start the service. It will honour BOTH key-agreement keys, so peers");
eprintln!(" holding a cached DID document can still reach it until the old one expires.");
} else {
eprintln!(" Start the service.");
}
eprintln!();
Ok(())
}
#[cfg(test)]
mod verifier_tests {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use affinidi_did_resolver_cache_sdk::{
AsyncResolver, DIDCacheClient, MethodName, Resolution, ResolverError,
config::DIDCacheConfigBuilder,
};
use affinidi_tdk::did_common::{DID, Document};
use affinidi_tdk::secrets_resolver::secrets::Secret;
use did_hosting_common::server::trust_tasks::{
BoundError, TransportBoundVerifier, send::build_request, sign_document, verify_sender_bound,
};
use serde_json::{Value, json};
use trust_tasks_rs::{RejectReason, TrustTask};
use super::control_verifier;
const TYPE: &str = "https://trusttasks.org/spec/webvh/sync/delete/0.1";
const ME: &str = "did:example:daemon";
async fn cache() -> DIDCacheClient {
DIDCacheClient::new(DIDCacheConfigBuilder::default().build())
.await
.unwrap()
}
fn verifier(client: &DIDCacheClient) -> Arc<TransportBoundVerifier> {
control_verifier(Some(client)).expect("a resolver is configured")
}
fn key(seed: u8, did: &str) -> Secret {
let mut secret = Secret::generate_ed25519(None, Some(&[seed; 32]));
secret.id = format!("{did}#key-1");
secret
}
fn document(did: &str, signer: &Secret) -> Document {
serde_json::from_value(json!({
"id": did,
"verificationMethod": [{
"id": format!("{did}#key-1"),
"type": "Multikey",
"controller": did,
"publicKeyMultibase": signer.get_public_keymultibase().unwrap(),
}],
"authentication": [format!("{did}#key-1")],
}))
.unwrap()
}
async fn signed(did: &str, signer: &Secret) -> TrustTask<Value> {
let doc = build_request(TYPE, did, ME, json!({ "mnemonic": "alice" })).unwrap();
sign_document(&doc, signer).await.unwrap()
}
async fn check(
doc: &TrustTask<Value>,
did: &str,
verifier: &TransportBoundVerifier,
) -> Result<String, BoundError> {
verify_sender_bound(doc, Some(did), Some(did), ME, verifier).await
}
#[tokio::test]
async fn a_deactivated_webvh_signer_is_refused() {
let mut client = cache().await;
let live = "did:webvh:QmLive:live.example";
let dead = "did:webvh:QmDead:dead.example";
let (live_key, dead_key) = (key(1, live), key(2, dead));
client
.add_did_document(live, document(live, &live_key))
.await;
client
.add_did_document(dead, document(dead, &dead_key))
.await;
let v = verifier(&client);
v.record_deactivation_verdict(live, false);
v.record_deactivation_verdict(dead, true);
check(&signed(live, &live_key).await, live, &v)
.await
.expect("a live signer verifies");
let err = check(&signed(dead, &dead_key).await, dead, &v)
.await
.unwrap_err();
assert!(err.to_string().contains("deactivated"), "{err}");
assert!(!err.is_transient(), "{err:?}");
assert!(
matches!(err.reject_reason(), RejectReason::ProofInvalid { .. }),
"{err:?}"
);
}
struct RotatingResolver {
keys: Arc<std::sync::Mutex<Secret>>,
resolutions: Arc<AtomicUsize>,
}
impl AsyncResolver for RotatingResolver {
fn name(&self) -> &str {
"RotatingResolver"
}
fn resolve<'a>(
&'a self,
did: &'a DID,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Resolution> + Send + 'a>> {
self.resolutions.fetch_add(1, Ordering::SeqCst);
let current = self.keys.lock().unwrap().clone();
let did = did.to_string();
Box::pin(async move { Some(Ok(document(&did, ¤t))) })
}
}
#[tokio::test]
async fn a_rotated_key_verifies_after_one_refresh() {
let did = "did:web:rotated.example";
let (old_key, new_key) = (key(3, did), key(4, did));
let keys = Arc::new(std::sync::Mutex::new(old_key));
let resolutions = Arc::new(AtomicUsize::new(0));
let mut client = cache().await;
client.set_resolver(
MethodName::Web,
Box::new(RotatingResolver {
keys: keys.clone(),
resolutions: resolutions.clone(),
}),
);
client.resolve(did).await.unwrap();
assert_eq!(resolutions.load(Ordering::SeqCst), 1);
let v = verifier(&client);
*keys.lock().unwrap() = new_key.clone();
let proven = check(&signed(did, &new_key).await, did, &v)
.await
.expect("verifies after one refresh");
assert_eq!(proven, did);
assert_eq!(resolutions.load(Ordering::SeqCst), 2, "exactly one refresh");
}
#[tokio::test]
async fn an_unreachable_signer_is_unavailable() {
struct Unreachable;
impl AsyncResolver for Unreachable {
fn name(&self) -> &str {
"Unreachable"
}
fn resolve<'a>(
&'a self,
_did: &'a DID,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Resolution> + Send + 'a>>
{
Box::pin(async {
Some(Err(ResolverError::ResolutionFailed(
"connection refused".to_string(),
)))
})
}
}
let did = "did:web:unreachable.example";
let signer = key(5, did);
let mut client = cache().await;
client.set_resolver(MethodName::Web, Box::new(Unreachable));
let err = check(&signed(did, &signer).await, did, &verifier(&client))
.await
.unwrap_err();
assert!(err.is_transient(), "{err:?}");
assert!(
matches!(err.reject_reason(), RejectReason::Unavailable { .. }),
"{err:?}"
);
}
#[tokio::test]
async fn a_webvh_signer_with_an_unreadable_log_is_unavailable() {
let did = "did:webvh:QmGone:gone.invalid";
let signer = key(6, did);
let mut client = cache().await;
client.add_did_document(did, document(did, &signer)).await;
let err = check(&signed(did, &signer).await, did, &verifier(&client))
.await
.unwrap_err();
assert!(err.is_transient(), "{err:?}");
assert!(
matches!(err.reject_reason(), RejectReason::Unavailable { .. }),
"{err:?}"
);
}
}