use clap::{Parser, Subcommand};
use did_hosting_common::server::store::KS_DIDS;
use did_hosting_server::config::AppConfig;
use did_hosting_server::{
backup, bootstrap, health, secret_store, server, setup, setup_recipe, store,
};
use std::path::PathBuf;
#[derive(Parser)]
#[command(name = "did-hosting-server", about = "DID Hosting Server", 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,
},
Health,
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,
},
Backup {
#[arg(short, long, default_value = "webvh-backup.json")]
output: String,
},
Restore {
#[arg(short, long)]
input: String,
},
LoadDid {
#[arg(long)]
path: String,
#[arg(long)]
did_log: PathBuf,
#[arg(long)]
did_witness: Option<PathBuf>,
},
RecreateDid {
#[arg(long)]
path: String,
},
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>,
},
RecoverDid {
#[arg(long)]
path: String,
},
DumpDid {
#[arg(long)]
path: String,
#[arg(long)]
witness: bool,
},
ListDids,
RemoveDid {
#[arg(long)]
path: String,
},
ExportSealed {
#[arg(long)]
request: PathBuf,
#[arg(long, default_value = "sealed-export.txt")]
out: PathBuf,
#[arg(long)]
digest_out: Option<PathBuf>,
},
ImportSealed {
#[arg(long)]
bundle: PathBuf,
#[arg(long)]
expect_digest: String,
#[arg(long, default_value = "bootstrap-seed.bin")]
seed: PathBuf,
#[arg(long)]
producer_pubkey: Option<String>,
#[arg(long)]
force: bool,
},
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)]
vta_credential: Option<String>,
#[arg(long)]
force: 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,
},
VtaRequest {
#[arg(long, default_value = "bootstrap-request.json")]
out: PathBuf,
#[arg(long, default_value = "bootstrap-seed.bin")]
seed: PathBuf,
#[arg(long, default_value = "did-hosting-server")]
label: String,
#[arg(long)]
public_url: String,
#[arg(long, default_value = "webvh")]
context: String,
#[arg(long)]
mediator_did: Option<String>,
#[arg(long, default_value = "both")]
transport: String,
},
VtaOpen {
#[arg(long)]
bundle: PathBuf,
#[arg(long)]
expect_digest: String,
#[arg(long, default_value = "bootstrap-seed.bin")]
seed: PathBuf,
#[arg(long, default_value = "server-did.json")]
did_doc_out: PathBuf,
#[arg(long, default_value = "server-did.jsonl")]
did_log_out: PathBuf,
#[arg(long, default_value = "server-secrets.json")]
secrets_out: PathBuf,
},
}
#[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"));
match setup_recipe::run_uninstall(&config_path, yes).await {
Ok(()) => {}
Err(e) => {
eprintln!("Uninstall error: {e}");
std::process::exit(1);
}
}
}
Some(Command::Health) => {
if let Err(e) = health::run_health(cli.config).await {
eprintln!("Health check 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::Backup { output }) => {
if let Err(e) = backup::run_backup(cli.config, output).await {
eprintln!("Backup error: {e}");
std::process::exit(1);
}
}
Some(Command::Restore { input }) => {
if let Err(e) = backup::run_restore(cli.config, input).await {
eprintln!("Restore 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::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::ImportSecrets {
vta_bundle,
signing_key,
ka_key,
vta_credential,
force,
}) => {
if let Err(e) = run_import_secrets(
cli.config,
vta_bundle,
signing_key,
ka_key,
vta_credential,
force,
)
.await
{
eprintln!("Error: {e}");
std::process::exit(1);
}
}
Some(Command::ExportSealed {
request,
out,
digest_out,
}) => {
if let Err(e) = run_export_sealed(cli.config, request, out, digest_out).await {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
Some(Command::ImportSealed {
bundle,
expect_digest,
seed,
producer_pubkey,
force,
}) => {
if let Err(e) = run_import_sealed(
cli.config,
bundle,
expect_digest,
seed,
producer_pubkey,
force,
)
.await
{
eprintln!("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::VtaRequest {
out,
seed,
label,
public_url,
context,
mediator_did,
transport,
}) => {
let transport =
match did_hosting_common::server::config::TransportSelection::parse(&transport) {
Ok(t) => t,
Err(e) => {
eprintln!("Error: {e}");
std::process::exit(1);
}
};
let (origin, did_path) =
did_hosting_common::server::setup_recipe::split_origin_and_did_path(&public_url);
let shape = did_hosting_common::server::vta_setup::WebvhDidShape::Hosted {
origin: &origin,
did_path: &did_path,
mediator_did: mediator_did.as_deref(),
transport,
remote: None,
};
let ask = did_hosting_common::server::vta_setup::build_webvh_provision_ask(
&context,
&shape,
Some(&label),
);
if let Err(e) = did_hosting_common::server::vta_setup::run_offline_request_cli(
&out,
&seed,
"did-hosting-server",
&ask,
)
.await
{
eprintln!("Error: {e}");
std::process::exit(1);
}
}
Some(Command::VtaOpen {
bundle,
expect_digest,
seed,
did_doc_out,
did_log_out,
secrets_out,
}) => {
if let Err(e) = did_hosting_common::server::vta_setup::run_offline_open_cli(
&bundle,
&expect_digest,
&seed,
&did_doc_out,
&did_log_out,
&secrets_out,
did_hosting_common::server::vta_setup::OfflineOpenNextStep::ImportSecrets {
binary: "did-hosting-server",
},
) {
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::DumpDid { path, witness }) => {
if let Err(e) = run_dump_did(cli.config, path, witness).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);
}
}
None => run_server(cli.config).await,
}
}
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 = AppConfig::load(config_path)?;
let store = 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 =
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_recover_did(
config_path: Option<PathBuf>,
mnemonic: String,
) -> Result<(), Box<dyn std::error::Error>> {
use did_hosting_common::did_ops::DidRecord;
let config = AppConfig::load(config_path)?;
let store_instance = store::Store::open(&config.store).await?;
let dids_ks = store_instance.keyspace(KS_DIDS)?;
let did_key = format!("did:{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_instance.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_recreate_did(
config_path: Option<PathBuf>,
mnemonic: String,
) -> Result<(), Box<dyn std::error::Error>> {
use affinidi_tdk::secrets_resolver::secrets::Secret;
let config = AppConfig::load(config_path)?;
let config_file = config.config_path.clone();
let public_url = config
.public_url
.as_deref()
.ok_or("public_url must be set in config")?;
let store = 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.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("system", &mnemonic))
.await?;
eprintln!(" Removed existing DID at path '{mnemonic}'");
}
let secret_store = secret_store::create_secret_store(&config)?;
let secrets = secret_store
.get()
.await?
.ok_or("no secrets found — run `did-hosting-server 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?;
setup::update_server_did_in_config(&config_file, &result.did_id)?;
let url_path = if mnemonic == ".well-known" {
".well-known/did.jsonl".to_string()
} else {
format!("{mnemonic}/did.jsonl")
};
eprintln!();
eprintln!(" DID recreated at path '{mnemonic}'!");
eprintln!();
eprintln!(" DID: {}", result.did_id);
eprintln!(" SCID: {}", result.scid);
eprintln!(" JSONL: {public_url}/{url_path}");
eprintln!();
eprintln!(" config.toml updated with new server_did.");
eprintln!();
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;
if did_witness.is_some() && did_log.is_none() {
return Err("--did-witness requires --did-log".into());
}
let config = AppConfig::load(config_path)?;
let public_url = config
.public_url
.as_deref()
.ok_or("public_url must be set in config for bootstrap")?;
let store = 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,
};
let result = bootstrap::import_did_at_path(
&store,
&dids_ks,
&mnemonic,
&jsonl,
witness_content.as_deref(),
)
.await?;
if did_witness.is_some() {
eprintln!(" Witness data imported.");
}
result
} else {
let secret_store = secret_store::create_secret_store(&config)?;
let secrets = secret_store
.get()
.await?
.ok_or("no secrets found — run `did-hosting-server 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 is_root = mnemonic == ".well-known";
let url_path = if is_root {
".well-known/did.jsonl".to_string()
} else {
format!("{mnemonic}/did.jsonl")
};
eprintln!();
if is_root {
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!();
if is_root && config.server_did.is_none() {
eprintln!(" Hint: set server_did in your config.toml:");
eprintln!(" server_did = \"{}\"", result.did_id);
eprintln!();
}
Ok(())
}
async fn run_dump_did(
config_path: Option<PathBuf>,
mnemonic: String,
witness: bool,
) -> Result<(), Box<dyn std::error::Error>> {
use did_hosting_common::did_ops::{content_log_key, content_witness_key, did_key};
let config = AppConfig::load(config_path)?;
let store_handle = store::Store::open(&config.store).await?;
let dids_ks = store_handle.keyspace(KS_DIDS)?;
let _: did_hosting_common::did_ops::DidRecord = dids_ks
.get(did_key(&mnemonic))
.await?
.ok_or(format!("DID not found at path '{mnemonic}'"))?;
let log_bytes = dids_ks
.get_raw(content_log_key(&mnemonic))
.await?
.ok_or(format!("no log content for path '{mnemonic}'"))?;
let log = String::from_utf8(log_bytes)
.map_err(|_| format!("invalid UTF-8 in log content for '{mnemonic}'"))?;
print!("{log}");
if witness {
if let Some(witness_bytes) = dids_ks.get_raw(content_witness_key(&mnemonic)).await? {
let witness_str = String::from_utf8(witness_bytes)
.map_err(|_| format!("invalid UTF-8 in witness content for '{mnemonic}'"))?;
eprintln!("--- did-witness.json ---");
print!("{witness_str}");
} else {
eprintln!("(no witness content)");
}
}
Ok(())
}
async fn run_list_dids(config_path: Option<PathBuf>) -> Result<(), Box<dyn std::error::Error>> {
use did_hosting_common::did_ops::DidRecord;
let config = AppConfig::load(config_path)?;
let store_handle = store::Store::open(&config.store).await?;
let dids_ks = store_handle.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>> {
use did_hosting_common::did_ops::{
DidRecord, content_log_key, content_witness_key, did_key, owner_key,
};
let config = AppConfig::load(config_path)?;
let store_handle = store::Store::open(&config.store).await?;
let dids_ks = store_handle.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_handle.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_import_secrets(
config_path: Option<PathBuf>,
vta_bundle: Option<String>,
signing_key: Option<String>,
ka_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 = AppConfig::load(config_path)?;
let secret_store = secret_store::create_secret_store(&config)?;
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 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 = secret_store::ServerSecrets {
signing_key: resolved_signing,
key_agreement_key: resolved_ka,
jwt_signing_key,
vta_credential: resolved_vta_cred,
retired,
};
secret_store.set(&server_secrets).await?;
eprintln!();
eprintln!(" Secrets imported successfully!");
eprintln!();
if secret_store::is_plaintext_backend(&config.secrets) {
eprintln!(" WARNING: secrets stored in plaintext — not for production use.");
eprintln!();
}
Ok(())
}
async fn run_server(config_path: Option<PathBuf>) {
let config = match AppConfig::load(config_path) {
Ok(config) => config,
Err(e) => {
eprintln!("Error: {e}");
eprintln!();
eprintln!("Create a config.toml or specify one:");
eprintln!(" did-hosting-server --config <path>");
eprintln!();
eprintln!("Or run the setup wizard:");
eprintln!(" did-hosting-server setup");
std::process::exit(1);
}
};
did_hosting_common::server::config::init_tracing(&config.log);
let secret_store = match secret_store::create_secret_store(&config) {
Ok(s) => s,
Err(e) => {
eprintln!("Error: {e}");
std::process::exit(1);
}
};
let secrets = match secret_store.get().await {
Ok(Some(s)) => {
tracing::info!("secrets loaded from secret store");
s
}
Ok(None) => {
eprintln!("Error: no secrets found — run `did-hosting-server setup` first");
std::process::exit(1);
}
Err(e) => {
eprintln!("Error loading secrets: {e}");
std::process::exit(1);
}
};
if secret_store::is_plaintext_backend(&config.secrets) {
tracing::warn!("============================================================");
tracing::warn!(" PLAINTEXT SECRETS MODE - INSECURE");
tracing::warn!(" Server secrets are stored as plaintext in the config file.");
tracing::warn!(" DO NOT use this in production.");
tracing::warn!(" For production, recompile with a secure backend:");
tracing::warn!(" keyring, aws-secrets, or gcp-secrets");
tracing::warn!("============================================================");
}
let store = store::Store::open_with(&config.store, &config.fjall)
.await
.expect("failed to open store");
if let Err(e) = server::run(config, store, secrets).await {
tracing::error!("server error: {e}");
std::process::exit(1);
}
}
async fn run_export_sealed(
config_path: Option<PathBuf>,
request: PathBuf,
out: PathBuf,
digest_out: Option<PathBuf>,
) -> Result<(), Box<dyn std::error::Error>> {
let config = AppConfig::load(config_path)?;
let producer_did = config
.server_did
.clone()
.ok_or("server_did not set in config — run setup first")?;
let secret_store = secret_store::create_secret_store(&config)?;
let secrets = secret_store
.get()
.await?
.ok_or("no secrets found — run setup or import-secrets first")?;
let info = did_hosting_common::server::vta_setup::export_sealed_did_secrets(
&request,
&out,
&producer_did,
&producer_did,
secrets.signing_key.clone(),
secrets.key_agreement_key.clone(),
did_hosting_common::server::vta_setup::ExportAssertionMode::DidSigned {
signing_key_multibase: secrets.signing_key.clone(),
verification_method: format!("{producer_did}#key-0"),
},
)
.await?;
if let Some(ref digest_path) = digest_out {
if let Some(parent) = digest_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(digest_path, format!("{}\n", info.digest))?;
}
eprintln!();
eprintln!(" Sealed export ready.");
eprintln!();
eprintln!(" Out: {}", info.out_path.display());
eprintln!(" Recipient DID: {}", info.recipient_did);
eprintln!(" Bundle id: {}", info.bundle_id_hex);
eprintln!();
eprintln!(" SHA-256 digest (send OOB to receiver):");
eprintln!(" {}", info.digest);
if let Some(ref p) = digest_out {
eprintln!(" Digest also written to {}", p.display());
}
eprintln!();
let producer_pub = affinidi_tdk::secrets_resolver::secrets::Secret::from_multibase(
&secrets.signing_key,
None,
)?
.get_public_keymultibase()?;
eprintln!(" Producer assertion mode: DidSigned. The bundle is signed by");
eprintln!(" this server's `#key-0` Ed25519 key. Receivers who pin the");
eprintln!(" producer pubkey via --producer-pubkey get cryptographic");
eprintln!(" verification; without it the OOB digest stays the only anchor.");
eprintln!();
eprintln!(" Producer pubkey (share with receiver — matches #key-0):");
eprintln!(" {producer_pub}");
eprintln!();
eprintln!(" Next steps on the receiver:");
eprintln!(
" did-hosting-server import-sealed --bundle {} \\\n --expect-digest {} \\\n --producer-pubkey {producer_pub}",
info.out_path.display(),
info.digest
);
eprintln!();
Ok(())
}
async fn run_import_sealed(
config_path: Option<PathBuf>,
bundle: PathBuf,
expect_digest: String,
seed: PathBuf,
producer_pubkey: 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, open_sealed_did_secrets,
};
let config = AppConfig::load(config_path)?;
let secret_store = secret_store::create_secret_store(&config)?;
if !force && let Ok(Some(_)) = secret_store.get().await {
return Err("secrets already exist — use --force to overwrite".into());
}
let expected_pubkey_bytes = match producer_pubkey.as_deref() {
Some(mb) => Some(decode_ed25519_pubkey_multibase(mb)?),
None => None,
};
let armor =
std::fs::read_to_string(&bundle).map_err(|e| format!("read {}: {e}", bundle.display()))?;
let result = open_sealed_did_secrets(
&armor,
&expect_digest,
&seed,
expected_pubkey_bytes.as_ref(),
)?;
Secret::from_multibase(&result.signing_key_multibase, None)
.map_err(|e| format!("invalid signing key in bundle: {e}"))?;
Secret::from_multibase(&result.key_agreement_multibase, None)
.map_err(|e| format!("invalid key-agreement key in bundle: {e}"))?;
let 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 = secret_store::ServerSecrets {
signing_key: result.signing_key_multibase,
key_agreement_key: result.key_agreement_multibase,
jwt_signing_key,
vta_credential: None,
retired,
};
secret_store.set(&server_secrets).await?;
eprintln!();
eprintln!(" Sealed bundle opened and imported.");
eprintln!();
eprintln!(" DID: {}", result.did);
if result.assertion_verified {
eprintln!(
" Producer DID: {} (DidSigned — signature verified)",
result.producer_did
);
} else {
eprintln!(
" Producer DID: {} (informational — no producer pubkey supplied)",
result.producer_did
);
}
eprintln!();
eprintln!(
" Note: update server_did in config.toml to {} if not already set.",
result.did
);
eprintln!();
Ok(())
}
fn decode_ed25519_pubkey_multibase(mb: &str) -> Result<[u8; 32], Box<dyn std::error::Error>> {
let (_, raw) =
multibase::decode(mb).map_err(|e| format!("invalid producer pubkey multibase: {e}"))?;
let pk_bytes: &[u8] = if raw.len() == 34 && raw[0] == 0xed && raw[1] == 0x01 {
&raw[2..]
} else {
&raw[..]
};
pk_bytes
.try_into()
.map_err(|_| "producer pubkey is not a 32-byte Ed25519 key".into())
}
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}██╗ ██╗{reset}
{cyan}██║ ██║{magenta}██╔════╝{yellow}██╔══██╗{cyan}██║ ██║{magenta}██║ ██║{reset}
{cyan}██║ █╗ ██║{magenta}█████╗ {yellow}██████╔╝{cyan}██║ ██║{magenta}███████║{reset}
{cyan}██║███╗██║{magenta}██╔══╝ {yellow}██╔══██╗{cyan}╚██╗ ██╔╝{magenta}██╔══██║{reset}
{cyan}╚███╔███╔╝{magenta}███████╗{yellow}██████╔╝{cyan} ╚████╔╝ {magenta}██║ ██║{reset}
{cyan} ╚══╝╚══╝ {magenta}╚══════╝{yellow}╚═════╝ {cyan} ╚═══╝ {magenta}╚═╝ ╚═╝{reset}
{dim} DID Hosting Server v{version}{reset}
"#,
version = env!("CARGO_PKG_VERSION"),
);
}
async fn run_tsp_relationship_list(
config_path: Option<PathBuf>,
) -> Result<(), Box<dyn std::error::Error>> {
let config = AppConfig::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 = AppConfig::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 = AppConfig::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 = AppConfig::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 = AppConfig::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 = AppConfig::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(())
}