use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use anyhow::{Result, anyhow};
use auths_utils::path::expand_tilde;
use clap::{Parser, Subcommand};
use auths_infra_http::HttpAsyncWitnessClient;
use auths_sdk::ports::IdentityStorage;
use auths_sdk::storage::RegistryIdentityStorage;
use auths_sdk::witness::AsyncWitnessProvider;
use auths_sdk::witness::{
EquivocationDetection, IndependencePolicy, WitnessConfig, WitnessRef, honesty_ceiling,
};
use auths_sdk::witness::{
WitnessServerConfig, WitnessServerState, generate_and_persist_witness_signer,
load_witness_signer, run_server, witness_signer_from_seed_hex,
};
#[derive(Parser, Debug, Clone)]
pub struct WitnessCommand {
#[command(subcommand)]
pub subcommand: WitnessSubcommand,
}
#[derive(Subcommand, Debug, Clone)]
pub enum WitnessSubcommand {
#[command(visible_alias = "serve")]
Start {
#[clap(long, default_value = "127.0.0.1:3333")]
bind: SocketAddr,
#[clap(long, default_value = "witness.db")]
db_path: PathBuf,
#[clap(long, visible_alias = "id")]
identity: Option<PathBuf>,
#[clap(long)]
generate: bool,
#[clap(long, default_value = "p256")]
curve: String,
},
Add {
#[clap(long)]
url: String,
},
Remove {
#[clap(long)]
url: String,
},
List,
}
fn parse_curve_arg(curve: &str) -> Result<auths_crypto::CurveType> {
match curve {
"p256" | "P256" => Ok(auths_crypto::CurveType::P256),
"ed25519" | "Ed25519" => Ok(auths_crypto::CurveType::Ed25519),
other => Err(anyhow!(
"unknown --curve '{other}'; expected 'p256' or 'ed25519'"
)),
}
}
fn build_witness_config(
db_path: PathBuf,
identity: Option<PathBuf>,
generate: bool,
curve: auths_crypto::CurveType,
) -> Result<WitnessServerConfig> {
#[allow(clippy::disallowed_methods)]
let env_seed = std::env::var("AUTHS_WITNESS_SEED").ok();
if let Some(seed_hex) = env_seed {
let signer = witness_signer_from_seed_hex(curve, &seed_hex)
.map_err(|e| anyhow!("invalid AUTHS_WITNESS_SEED: {e}"))?;
return WitnessServerConfig::from_signer(db_path, signer)
.map_err(|e| anyhow!("witness config from injected seed: {e}"));
}
if let Some(identity_path) = identity {
let path =
expand_tilde(&identity_path).map_err(|e| anyhow!("invalid --identity path: {e}"))?;
let signer = if path.exists() {
load_witness_signer(&path).map_err(|e| anyhow!("{e}"))?
} else if generate {
let signer =
generate_and_persist_witness_signer(&path, curve).map_err(|e| anyhow!("{e}"))?;
println!("Generated new witness identity at {}", path.display());
signer
} else {
return Err(anyhow!(
"no witness identity at {}; pass --generate to create one \
(refusing to mint an ephemeral key for a --identity path)",
path.display()
));
};
return WitnessServerConfig::from_signer(db_path, signer)
.map_err(|e| anyhow!("witness config: {e}"));
}
eprintln!(
"warning: starting with an EPHEMERAL witness identity (new AID each launch, \
not pinnable); pass --identity <path> --generate for a stable identity"
);
WitnessServerConfig::with_generated_keypair(db_path, curve)
.map_err(|e| anyhow!("Failed to generate witness keypair: {e}"))
}
pub fn handle_witness(cmd: WitnessCommand, repo_opt: Option<PathBuf>) -> Result<()> {
match cmd.subcommand {
WitnessSubcommand::Start {
bind,
db_path,
identity,
generate,
curve,
} => {
let curve = parse_curve_arg(&curve)?;
let cfg = build_witness_config(db_path, identity, generate, curve)?;
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async {
let state = WitnessServerState::new(cfg)
.map_err(|e| anyhow::anyhow!("Failed to create witness state: {}", e))?;
println!(
"Witness server started at {} (identity: {})",
bind,
state.witness_did()
);
run_server(state, bind)
.await
.map_err(|e| anyhow::anyhow!("Server error: {}", e))?;
Ok(())
})
}
WitnessSubcommand::Add { url } => {
let repo_path = resolve_repo_path(repo_opt)?;
let parsed_url: url::Url = url
.parse()
.map_err(|e| anyhow!("Invalid witness URL '{}': {}", url, e))?;
let mut config = load_witness_config(&repo_path)?;
let rt = tokio::runtime::Runtime::new()?;
let aid = rt
.block_on(async {
let client = HttpAsyncWitnessClient::new(
parsed_url.to_string(),
config.threshold.max(1),
);
client.witness_aid().await
})
.map_err(|e| {
anyhow!(
"Could not resolve witness identity from {}/health: {}",
parsed_url,
e
)
})?;
if !config.pin(WitnessRef {
url: parsed_url.clone(),
aid: aid.clone(),
operator_info: None,
}) {
println!("Witness already configured (aid {}): {}", aid.as_str(), url);
return Ok(());
}
if config.threshold == 0 {
config.threshold = 1;
}
save_witness_config(&repo_path, &config)?;
println!("Added witness: {} (aid {})", url, aid.as_str());
println!(
" Witnesses: {}, required: {}",
config.witnesses.len(),
config.threshold
);
Ok(())
}
WitnessSubcommand::Remove { url } => {
let repo_path = resolve_repo_path(repo_opt)?;
let parsed_url: url::Url = url
.parse()
.map_err(|e| anyhow!("Invalid witness URL '{}': {}", url, e))?;
let mut config = load_witness_config(&repo_path)?;
if !config.remove_url(&parsed_url) {
println!("Witness not found: {}", url);
return Ok(());
}
if config.threshold > config.witnesses.len() {
config.threshold = config.witnesses.len();
}
save_witness_config(&repo_path, &config)?;
println!("Removed witness: {}", url);
println!(
" Remaining witnesses: {}, required: {}",
config.witnesses.len(),
config.threshold
);
Ok(())
}
WitnessSubcommand::List => {
let repo_path = resolve_repo_path(repo_opt)?;
let config = load_witness_config(&repo_path)?;
if config.witnesses.is_empty() {
println!("No witnesses configured.");
return Ok(());
}
println!("Configured witnesses:");
for (i, w) in config.witnesses.iter().enumerate() {
println!(" {}. {} (aid {})", i + 1, w.url, w.aid.as_str());
}
println!(
"\nRequired: {}/{} (policy: {:?})",
config.threshold,
config.witnesses.len(),
config.policy
);
let independence = config.roster_independence(&IndependencePolicy::default());
let ceiling = honesty_ceiling(&independence, EquivocationDetection::Sampled);
let status = if ceiling.policy_met { "MET" } else { "FAILING" };
println!("\nIndependence: {status} — {}", ceiling.label);
if !ceiling.shortfalls.is_empty() {
println!(" shortfall: {}", ceiling.shortfalls.join(", "));
}
Ok(())
}
}
}
fn resolve_repo_path(repo_opt: Option<PathBuf>) -> Result<PathBuf> {
if let Some(path) = repo_opt {
return Ok(expand_tilde(&path)?);
}
let home = dirs::home_dir().ok_or_else(|| anyhow!("Could not determine home directory"))?;
Ok(home.join(".auths"))
}
fn load_witness_config(repo_path: &Path) -> Result<WitnessConfig> {
let storage = RegistryIdentityStorage::new(repo_path.to_path_buf());
let identity = storage.load_identity()?;
if let Some(ref metadata) = identity.metadata
&& let Some(wc) = metadata.get("witness_config")
{
let config: WitnessConfig = serde_json::from_value(wc.clone())?;
return Ok(config);
}
Ok(WitnessConfig::default())
}
fn save_witness_config(repo_path: &Path, config: &WitnessConfig) -> Result<()> {
let storage = RegistryIdentityStorage::new(repo_path.to_path_buf());
let mut identity = storage.load_identity()?;
let metadata = identity
.metadata
.get_or_insert_with(|| serde_json::json!({}));
if let Some(obj) = metadata.as_object_mut() {
obj.insert("witness_config".to_string(), serde_json::to_value(config)?);
}
storage.create_identity(identity.controller_did.as_str(), identity.metadata)?;
Ok(())
}
impl crate::commands::executable::ExecutableCommand for WitnessCommand {
fn execute(&self, ctx: &crate::config::CliConfig) -> anyhow::Result<()> {
handle_witness(self.clone(), ctx.repo_path.clone())
}
}