1use std::net::SocketAddr;
4use std::path::{Path, PathBuf};
5
6use anyhow::{Result, anyhow};
7use auths_utils::path::expand_tilde;
8use clap::{Parser, Subcommand};
9
10use auths_infra_http::HttpAsyncWitnessClient;
11use auths_sdk::ports::IdentityStorage;
12use auths_sdk::storage::RegistryIdentityStorage;
13use auths_sdk::witness::AsyncWitnessProvider;
14use auths_sdk::witness::{
15 EquivocationDetection, IndependencePolicy, WitnessConfig, WitnessRef, honesty_ceiling,
16};
17use auths_sdk::witness::{
18 WitnessServerConfig, WitnessServerState, generate_and_persist_witness_signer,
19 load_witness_signer, run_server, witness_signer_from_seed_hex,
20};
21
22#[derive(Parser, Debug, Clone)]
24pub struct WitnessCommand {
25 #[command(subcommand)]
26 pub subcommand: WitnessSubcommand,
27}
28
29#[derive(Subcommand, Debug, Clone)]
31pub enum WitnessSubcommand {
32 Up {
38 #[clap(long, default_value_t = 3333)]
40 port: u16,
41
42 #[clap(long, default_value = "./witness-data")]
44 data_dir: PathBuf,
45
46 #[clap(long)]
50 accept_file_key: bool,
51
52 #[clap(long)]
56 image: Option<String>,
57
58 #[clap(long)]
63 build_attestation: Option<PathBuf>,
64 },
65
66 Down {
68 #[clap(long, default_value = "./witness-data")]
70 data_dir: PathBuf,
71
72 #[clap(long, default_value_t = 3333)]
74 port: u16,
75 },
76
77 Status {
79 #[clap(long, default_value_t = 3333)]
81 port: u16,
82 },
83
84 #[command(name = "verify-receipt")]
93 VerifyReceipt {
94 #[clap(long)]
96 receipt: PathBuf,
97 },
98
99 Register {
101 #[clap(long)]
103 endpoint: String,
104 },
105
106 Logs {
108 #[clap(long, default_value = "./witness-data")]
110 data_dir: PathBuf,
111 },
112
113 #[command(visible_alias = "serve")]
115 Start {
116 #[clap(long, default_value = "127.0.0.1:3333")]
118 bind: SocketAddr,
119
120 #[clap(long, default_value = "witness.db")]
122 db_path: PathBuf,
123
124 #[clap(long, visible_alias = "id")]
129 identity: Option<PathBuf>,
130
131 #[clap(long)]
134 generate: bool,
135
136 #[clap(long, default_value = "p256")]
138 curve: String,
139 },
140
141 Add {
143 #[clap(long)]
145 url: String,
146 },
147
148 Remove {
150 #[clap(long)]
152 url: String,
153 },
154
155 List,
157
158 Publish,
165}
166
167fn parse_curve_arg(curve: &str) -> Result<auths_crypto::CurveType> {
169 match curve {
170 "p256" | "P256" => Ok(auths_crypto::CurveType::P256),
171 "ed25519" | "Ed25519" => Ok(auths_crypto::CurveType::Ed25519),
172 other => Err(anyhow!(
173 "unknown --curve '{other}'; expected 'p256' or 'ed25519'"
174 )),
175 }
176}
177
178fn build_witness_config(
185 db_path: PathBuf,
186 identity: Option<PathBuf>,
187 generate: bool,
188 curve: auths_crypto::CurveType,
189) -> Result<WitnessServerConfig> {
190 #[allow(clippy::disallowed_methods)]
191 let env_seed = std::env::var("AUTHS_WITNESS_SEED").ok();
194
195 if let Some(seed_hex) = env_seed {
196 let signer = witness_signer_from_seed_hex(curve, &seed_hex)
197 .map_err(|e| anyhow!("invalid AUTHS_WITNESS_SEED: {e}"))?;
198 return WitnessServerConfig::from_signer(db_path, signer)
199 .map_err(|e| anyhow!("witness config from injected seed: {e}"));
200 }
201
202 if let Some(identity_path) = identity {
203 let path =
204 expand_tilde(&identity_path).map_err(|e| anyhow!("invalid --identity path: {e}"))?;
205 let signer = if path.exists() {
206 load_witness_signer(&path).map_err(|e| anyhow!("{e}"))?
207 } else if generate {
208 let signer =
209 generate_and_persist_witness_signer(&path, curve).map_err(|e| anyhow!("{e}"))?;
210 println!("Generated new witness identity at {}", path.display());
211 signer
212 } else {
213 return Err(anyhow!(
214 "no witness identity at {}; pass --generate to create one \
215 (refusing to mint an ephemeral key for a --identity path)",
216 path.display()
217 ));
218 };
219 return WitnessServerConfig::from_signer(db_path, signer)
220 .map_err(|e| anyhow!("witness config: {e}"));
221 }
222
223 eprintln!(
224 "warning: starting with an EPHEMERAL witness identity (new AID each launch, \
225 not pinnable); pass --identity <path> --generate for a stable identity"
226 );
227 WitnessServerConfig::with_generated_keypair(db_path, curve)
228 .map_err(|e| anyhow!("Failed to generate witness keypair: {e}"))
229}
230
231pub fn handle_witness(cmd: WitnessCommand, repo_opt: Option<PathBuf>) -> Result<()> {
233 match cmd.subcommand {
234 WitnessSubcommand::Up {
235 port,
236 data_dir,
237 accept_file_key,
238 image,
239 build_attestation,
240 } => node::up(port, data_dir, accept_file_key, image, build_attestation),
241 WitnessSubcommand::Down { data_dir, port } => node::down(data_dir, port),
242 WitnessSubcommand::Status { port } => node::status(port),
243 WitnessSubcommand::VerifyReceipt { receipt } => node::verify_receipt(receipt),
244 WitnessSubcommand::Register { endpoint } => node::register(endpoint),
245 WitnessSubcommand::Logs { data_dir } => node::logs(data_dir),
246
247 WitnessSubcommand::Start {
248 bind,
249 db_path,
250 identity,
251 generate,
252 curve,
253 } => {
254 let curve = parse_curve_arg(&curve)?;
255 let cfg = build_witness_config(db_path, identity, generate, curve)?;
256 let rt = tokio::runtime::Runtime::new()?;
257 rt.block_on(async {
258 let state = WitnessServerState::new(cfg)
259 .map_err(|e| anyhow::anyhow!("Failed to create witness state: {}", e))?;
260
261 println!(
262 "Witness server started at {} (identity: {})",
263 bind,
264 state.witness_did()
265 );
266
267 run_server(state, bind)
268 .await
269 .map_err(|e| anyhow::anyhow!("Server error: {}", e))?;
270
271 Ok(())
272 })
273 }
274
275 WitnessSubcommand::Add { url } => {
276 let repo_path = resolve_repo_path(repo_opt)?;
277 let parsed_url: url::Url = url
278 .parse()
279 .map_err(|e| anyhow!("Invalid witness URL '{}': {}", url, e))?;
280 let mut config = load_witness_config(&repo_path)?;
281 let rt = tokio::runtime::Runtime::new()?;
286 let aid = rt
287 .block_on(async {
288 let client = HttpAsyncWitnessClient::new(
289 parsed_url.to_string(),
290 config.threshold.max(1),
291 );
292 client.witness_aid().await
293 })
294 .map_err(|e| {
295 anyhow!(
296 "Could not resolve witness identity from {}/health: {}",
297 parsed_url,
298 e
299 )
300 })?;
301 if !config.pin(WitnessRef {
302 url: parsed_url.clone(),
303 aid: aid.clone(),
304 operator_info: None,
308 }) {
309 println!("Witness already configured (aid {}): {}", aid.as_str(), url);
310 return Ok(());
311 }
312 if config.threshold == 0 {
313 config.threshold = 1;
314 }
315 save_witness_config(&repo_path, &config)?;
316 println!("Added witness: {} (aid {})", url, aid.as_str());
317 println!(
318 " Witnesses: {}, required: {}",
319 config.witnesses.len(),
320 config.threshold
321 );
322 Ok(())
323 }
324
325 WitnessSubcommand::Remove { url } => {
326 let repo_path = resolve_repo_path(repo_opt)?;
327 let parsed_url: url::Url = url
328 .parse()
329 .map_err(|e| anyhow!("Invalid witness URL '{}': {}", url, e))?;
330 let mut config = load_witness_config(&repo_path)?;
331 if !config.remove_url(&parsed_url) {
332 println!("Witness not found: {}", url);
333 return Ok(());
334 }
335 if config.threshold > config.witnesses.len() {
337 config.threshold = config.witnesses.len();
338 }
339 save_witness_config(&repo_path, &config)?;
340 println!("Removed witness: {}", url);
341 println!(
342 " Remaining witnesses: {}, required: {}",
343 config.witnesses.len(),
344 config.threshold
345 );
346 Ok(())
347 }
348
349 WitnessSubcommand::List => {
350 let repo_path = resolve_repo_path(repo_opt)?;
351 let config = load_witness_config(&repo_path)?;
352 if config.witnesses.is_empty() {
353 println!("No witnesses configured.");
354 return Ok(());
355 }
356 println!("Configured witnesses:");
357 for (i, w) in config.witnesses.iter().enumerate() {
358 println!(" {}. {} (aid {})", i + 1, w.url, w.aid.as_str());
359 }
360 println!(
361 "\nRequired: {}/{} (policy: {:?})",
362 config.threshold,
363 config.witnesses.len(),
364 config.policy
365 );
366
367 let independence = config.roster_independence(&IndependencePolicy::default());
370 let ceiling = honesty_ceiling(&independence, EquivocationDetection::Sampled);
371 let status = if ceiling.policy_met { "MET" } else { "FAILING" };
372 println!("\nIndependence: {status} — {}", ceiling.label);
373 if !ceiling.shortfalls.is_empty() {
374 println!(" shortfall: {}", ceiling.shortfalls.join(", "));
375 }
376 Ok(())
377 }
378
379 WitnessSubcommand::Publish => {
380 let repo_path = resolve_repo_path(repo_opt)?;
381 let config = load_witness_config(&repo_path)?;
382 if config.witnesses.is_empty() {
383 return Err(anyhow!(
384 "no witnesses configured — add one first: auths witness add --url <URL>"
385 ));
386 }
387 let storage = RegistryIdentityStorage::new(repo_path.clone());
388 let identity = storage.load_identity()?;
389 let prefix_str = identity
390 .controller_did
391 .as_str()
392 .strip_prefix("did:keri:")
393 .ok_or_else(|| anyhow!("identity is not did:keri: {}", identity.controller_did))?;
394 let prefix = auths_keri::Prefix::new(prefix_str.to_string())
395 .map_err(|e| anyhow!("invalid identity prefix: {e}"))?;
396
397 let backend = auths_sdk::storage::GitRegistryBackend::from_config_unchecked(
398 auths_sdk::storage::RegistryConfig::single_tenant(&repo_path),
399 );
400 let witness = auths_sdk::witness::WitnessParams::Enabled {
401 config: &config,
402 repo_path: &repo_path,
403 };
404 let published = auths_sdk::witness::publish_kel_to_backers(
405 &backend,
406 &witness,
407 &prefix,
408 chrono::Utc::now(),
409 )
410 .map_err(|e| anyhow!("publish failed: {e}"))?;
411 println!(
412 "Published {published} event(s) to {} witness(es) with {}-of-{} receipts each.",
413 config.witnesses.len(),
414 config.threshold,
415 config.witnesses.len(),
416 );
417 Ok(())
418 }
419 }
420}
421
422fn resolve_repo_path(repo_opt: Option<PathBuf>) -> Result<PathBuf> {
426 if let Some(path) = repo_opt {
427 return Ok(expand_tilde(&path)?);
428 }
429 let home = dirs::home_dir().ok_or_else(|| anyhow!("Could not determine home directory"))?;
430 Ok(home.join(".auths"))
431}
432
433fn load_witness_config(repo_path: &Path) -> Result<WitnessConfig> {
435 let storage = RegistryIdentityStorage::new(repo_path.to_path_buf());
436 let identity = storage.load_identity()?;
437
438 if let Some(ref metadata) = identity.metadata
439 && let Some(wc) = metadata.get("witness_config")
440 {
441 let config: WitnessConfig = serde_json::from_value(wc.clone())?;
442 return Ok(config);
443 }
444 Ok(WitnessConfig::default())
445}
446
447fn save_witness_config(repo_path: &Path, config: &WitnessConfig) -> Result<()> {
449 let storage = RegistryIdentityStorage::new(repo_path.to_path_buf());
450 let mut identity = storage.load_identity()?;
451
452 let metadata = identity
453 .metadata
454 .get_or_insert_with(|| serde_json::json!({}));
455 if let Some(obj) = metadata.as_object_mut() {
456 obj.insert("witness_config".to_string(), serde_json::to_value(config)?);
457 }
458
459 storage.create_identity(identity.controller_did.as_str(), identity.metadata)?;
460 Ok(())
461}
462
463#[cfg(feature = "witness-node")]
472mod node {
473 use std::path::PathBuf;
474 use std::time::Duration;
475
476 use std::io::Read;
477
478 use anyhow::{Result, anyhow};
479 use auths_witness_node::{
480 BuildAttestation, DockerEngine, HttpFetch, KeyCustody, NodeBuildVerdict,
481 OfflineReceiptVerdict, ReceiptBundle, SocketHealthCheck, SocketHttpFetch, StandupRequest,
482 stand_up, tear_down,
483 };
484
485 const HEALTH_WAIT: Duration = Duration::from_secs(540);
490
491 fn plan(
494 port: u16,
495 data_dir: PathBuf,
496 accept_file_key: bool,
497 image: Option<String>,
498 build_attestation: Option<PathBuf>,
499 ) -> StandupRequest {
500 let mut req = StandupRequest::local(data_dir);
501 req.host_port = port;
502 if accept_file_key {
505 req.custody = KeyCustody::File;
506 }
507 if let Some(reference) = image {
508 req.image.reference = reference;
509 }
510 req.build_attestation = build_attestation;
511 req
512 }
513
514 pub fn up(
515 port: u16,
516 data_dir: PathBuf,
517 accept_file_key: bool,
518 image: Option<String>,
519 build_attestation: Option<PathBuf>,
520 ) -> Result<()> {
521 let req = plan(port, data_dir, accept_file_key, image, build_attestation);
522 let outcome = stand_up(&req, &DockerEngine, &SocketHealthCheck, HEALTH_WAIT)
527 .map_err(|e| anyhow!("{e}"))?;
528 println!("health: {}", outcome.health_url);
529 Ok(())
530 }
531
532 pub fn down(data_dir: PathBuf, port: u16) -> Result<()> {
533 tear_down(&data_dir, port, &DockerEngine).map_err(|e| anyhow!("{e}"))?;
534 println!("witness node torn down");
535 Ok(())
536 }
537
538 pub fn status(port: u16) -> Result<()> {
539 use auths_witness_node::HealthCheck;
540 let health_url = format!("http://127.0.0.1:{port}/health");
541 if !SocketHealthCheck.is_healthy(&health_url) {
542 return Err(anyhow!(
543 "no node answering at {health_url} — is one stood up on port {port}?"
544 ));
545 }
546 println!("healthy: {health_url}");
547
548 let build_url = format!("http://127.0.0.1:{port}/build");
555 let response = SocketHttpFetch
556 .get(&build_url)
557 .map_err(|e| anyhow!("could not read the node's build proof: {e}"))?;
558 if !response.ok {
559 return Err(anyhow!(
560 "this node does not prove which binary it runs (no build attestation at \
561 {build_url}) — refuse to trust a node that cannot be vouched for"
562 ));
563 }
564 let build = BuildAttestation::from_json(&response.body)
565 .map_err(|e| anyhow!("the node's build proof is unreadable: {e}"))?;
566
567 let rt = tokio::runtime::Runtime::new()?;
568 match rt.block_on(build.verify()) {
569 verdict @ NodeBuildVerdict::Trusted { .. } => {
570 println!("{}", verdict.summary());
571 Ok(())
572 }
573 verdict => Err(anyhow!("{}", verdict.summary())),
574 }
575 }
576
577 pub fn verify_receipt(receipt: PathBuf) -> Result<()> {
584 let bytes = if receipt.as_os_str() == "-" {
585 let mut buf = Vec::new();
586 std::io::stdin()
587 .read_to_end(&mut buf)
588 .map_err(|e| anyhow!("could not read the receipt bundle from stdin: {e}"))?;
589 buf
590 } else {
591 std::fs::read(&receipt).map_err(|e| {
592 anyhow!(
593 "could not read the receipt bundle at {}: {e}",
594 receipt.display()
595 )
596 })?
597 };
598
599 let bundle = ReceiptBundle::from_json(&bytes)
600 .map_err(|e| anyhow!("the receipt bundle is not a readable receipt: {e}"))?;
601
602 match bundle.verify_offline() {
603 OfflineReceiptVerdict::Verified { witness } => {
604 println!("verified: this receipt was issued by {witness}");
605 Ok(())
606 }
607 OfflineReceiptVerdict::SignatureFailed { witness } => Err(anyhow!(
608 "rejected: this receipt does not verify against {witness} — \
609 it was altered or was not issued by that node"
610 )),
611 OfflineReceiptVerdict::UnreadableIdentity { reason } => Err(anyhow!(
612 "rejected: the witness identity in the bundle is unreadable: {reason}"
613 )),
614 }
615 }
616
617 pub fn register(endpoint: String) -> Result<()> {
618 println!("opening signed registration for {endpoint}");
619 Ok(())
620 }
621
622 pub fn logs(data_dir: PathBuf) -> Result<()> {
623 println!("streaming logs for witness node at {}", data_dir.display());
624 Ok(())
625 }
626}
627
628#[cfg(not(feature = "witness-node"))]
632mod node {
633 use std::path::PathBuf;
634
635 use anyhow::{Result, anyhow};
636
637 fn unavailable(verb: &str) -> Result<()> {
638 Err(anyhow!(
639 "`auths witness {verb}` needs the witness build; install it with \
640 `cargo install auths --features witness-node` (or use the \
641 witness-node release binary)"
642 ))
643 }
644
645 pub fn up(
646 _port: u16,
647 _data_dir: PathBuf,
648 _accept_file_key: bool,
649 _image: Option<String>,
650 _build_attestation: Option<PathBuf>,
651 ) -> Result<()> {
652 unavailable("up")
653 }
654 pub fn down(_data_dir: PathBuf, _port: u16) -> Result<()> {
655 unavailable("down")
656 }
657 pub fn status(_port: u16) -> Result<()> {
658 unavailable("status")
659 }
660 pub fn verify_receipt(_receipt: PathBuf) -> Result<()> {
661 unavailable("verify-receipt")
662 }
663 pub fn register(_endpoint: String) -> Result<()> {
664 unavailable("register")
665 }
666 pub fn logs(_data_dir: PathBuf) -> Result<()> {
667 unavailable("logs")
668 }
669}
670
671impl crate::commands::executable::ExecutableCommand for WitnessCommand {
672 fn execute(&self, ctx: &crate::config::CliConfig) -> anyhow::Result<()> {
673 handle_witness(self.clone(), ctx.repo_path.clone())
674 }
675}