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
159fn parse_curve_arg(curve: &str) -> Result<auths_crypto::CurveType> {
161 match curve {
162 "p256" | "P256" => Ok(auths_crypto::CurveType::P256),
163 "ed25519" | "Ed25519" => Ok(auths_crypto::CurveType::Ed25519),
164 other => Err(anyhow!(
165 "unknown --curve '{other}'; expected 'p256' or 'ed25519'"
166 )),
167 }
168}
169
170fn build_witness_config(
177 db_path: PathBuf,
178 identity: Option<PathBuf>,
179 generate: bool,
180 curve: auths_crypto::CurveType,
181) -> Result<WitnessServerConfig> {
182 #[allow(clippy::disallowed_methods)]
183 let env_seed = std::env::var("AUTHS_WITNESS_SEED").ok();
186
187 if let Some(seed_hex) = env_seed {
188 let signer = witness_signer_from_seed_hex(curve, &seed_hex)
189 .map_err(|e| anyhow!("invalid AUTHS_WITNESS_SEED: {e}"))?;
190 return WitnessServerConfig::from_signer(db_path, signer)
191 .map_err(|e| anyhow!("witness config from injected seed: {e}"));
192 }
193
194 if let Some(identity_path) = identity {
195 let path =
196 expand_tilde(&identity_path).map_err(|e| anyhow!("invalid --identity path: {e}"))?;
197 let signer = if path.exists() {
198 load_witness_signer(&path).map_err(|e| anyhow!("{e}"))?
199 } else if generate {
200 let signer =
201 generate_and_persist_witness_signer(&path, curve).map_err(|e| anyhow!("{e}"))?;
202 println!("Generated new witness identity at {}", path.display());
203 signer
204 } else {
205 return Err(anyhow!(
206 "no witness identity at {}; pass --generate to create one \
207 (refusing to mint an ephemeral key for a --identity path)",
208 path.display()
209 ));
210 };
211 return WitnessServerConfig::from_signer(db_path, signer)
212 .map_err(|e| anyhow!("witness config: {e}"));
213 }
214
215 eprintln!(
216 "warning: starting with an EPHEMERAL witness identity (new AID each launch, \
217 not pinnable); pass --identity <path> --generate for a stable identity"
218 );
219 WitnessServerConfig::with_generated_keypair(db_path, curve)
220 .map_err(|e| anyhow!("Failed to generate witness keypair: {e}"))
221}
222
223pub fn handle_witness(cmd: WitnessCommand, repo_opt: Option<PathBuf>) -> Result<()> {
225 match cmd.subcommand {
226 WitnessSubcommand::Up {
227 port,
228 data_dir,
229 accept_file_key,
230 image,
231 build_attestation,
232 } => node::up(port, data_dir, accept_file_key, image, build_attestation),
233 WitnessSubcommand::Down { data_dir, port } => node::down(data_dir, port),
234 WitnessSubcommand::Status { port } => node::status(port),
235 WitnessSubcommand::VerifyReceipt { receipt } => node::verify_receipt(receipt),
236 WitnessSubcommand::Register { endpoint } => node::register(endpoint),
237 WitnessSubcommand::Logs { data_dir } => node::logs(data_dir),
238
239 WitnessSubcommand::Start {
240 bind,
241 db_path,
242 identity,
243 generate,
244 curve,
245 } => {
246 let curve = parse_curve_arg(&curve)?;
247 let cfg = build_witness_config(db_path, identity, generate, curve)?;
248 let rt = tokio::runtime::Runtime::new()?;
249 rt.block_on(async {
250 let state = WitnessServerState::new(cfg)
251 .map_err(|e| anyhow::anyhow!("Failed to create witness state: {}", e))?;
252
253 println!(
254 "Witness server started at {} (identity: {})",
255 bind,
256 state.witness_did()
257 );
258
259 run_server(state, bind)
260 .await
261 .map_err(|e| anyhow::anyhow!("Server error: {}", e))?;
262
263 Ok(())
264 })
265 }
266
267 WitnessSubcommand::Add { url } => {
268 let repo_path = resolve_repo_path(repo_opt)?;
269 let parsed_url: url::Url = url
270 .parse()
271 .map_err(|e| anyhow!("Invalid witness URL '{}': {}", url, e))?;
272 let mut config = load_witness_config(&repo_path)?;
273 let rt = tokio::runtime::Runtime::new()?;
278 let aid = rt
279 .block_on(async {
280 let client = HttpAsyncWitnessClient::new(
281 parsed_url.to_string(),
282 config.threshold.max(1),
283 );
284 client.witness_aid().await
285 })
286 .map_err(|e| {
287 anyhow!(
288 "Could not resolve witness identity from {}/health: {}",
289 parsed_url,
290 e
291 )
292 })?;
293 if !config.pin(WitnessRef {
294 url: parsed_url.clone(),
295 aid: aid.clone(),
296 operator_info: None,
300 }) {
301 println!("Witness already configured (aid {}): {}", aid.as_str(), url);
302 return Ok(());
303 }
304 if config.threshold == 0 {
305 config.threshold = 1;
306 }
307 save_witness_config(&repo_path, &config)?;
308 println!("Added witness: {} (aid {})", url, aid.as_str());
309 println!(
310 " Witnesses: {}, required: {}",
311 config.witnesses.len(),
312 config.threshold
313 );
314 Ok(())
315 }
316
317 WitnessSubcommand::Remove { url } => {
318 let repo_path = resolve_repo_path(repo_opt)?;
319 let parsed_url: url::Url = url
320 .parse()
321 .map_err(|e| anyhow!("Invalid witness URL '{}': {}", url, e))?;
322 let mut config = load_witness_config(&repo_path)?;
323 if !config.remove_url(&parsed_url) {
324 println!("Witness not found: {}", url);
325 return Ok(());
326 }
327 if config.threshold > config.witnesses.len() {
329 config.threshold = config.witnesses.len();
330 }
331 save_witness_config(&repo_path, &config)?;
332 println!("Removed witness: {}", url);
333 println!(
334 " Remaining witnesses: {}, required: {}",
335 config.witnesses.len(),
336 config.threshold
337 );
338 Ok(())
339 }
340
341 WitnessSubcommand::List => {
342 let repo_path = resolve_repo_path(repo_opt)?;
343 let config = load_witness_config(&repo_path)?;
344 if config.witnesses.is_empty() {
345 println!("No witnesses configured.");
346 return Ok(());
347 }
348 println!("Configured witnesses:");
349 for (i, w) in config.witnesses.iter().enumerate() {
350 println!(" {}. {} (aid {})", i + 1, w.url, w.aid.as_str());
351 }
352 println!(
353 "\nRequired: {}/{} (policy: {:?})",
354 config.threshold,
355 config.witnesses.len(),
356 config.policy
357 );
358
359 let independence = config.roster_independence(&IndependencePolicy::default());
362 let ceiling = honesty_ceiling(&independence, EquivocationDetection::Sampled);
363 let status = if ceiling.policy_met { "MET" } else { "FAILING" };
364 println!("\nIndependence: {status} — {}", ceiling.label);
365 if !ceiling.shortfalls.is_empty() {
366 println!(" shortfall: {}", ceiling.shortfalls.join(", "));
367 }
368 Ok(())
369 }
370 }
371}
372
373fn resolve_repo_path(repo_opt: Option<PathBuf>) -> Result<PathBuf> {
377 if let Some(path) = repo_opt {
378 return Ok(expand_tilde(&path)?);
379 }
380 let home = dirs::home_dir().ok_or_else(|| anyhow!("Could not determine home directory"))?;
381 Ok(home.join(".auths"))
382}
383
384fn load_witness_config(repo_path: &Path) -> Result<WitnessConfig> {
386 let storage = RegistryIdentityStorage::new(repo_path.to_path_buf());
387 let identity = storage.load_identity()?;
388
389 if let Some(ref metadata) = identity.metadata
390 && let Some(wc) = metadata.get("witness_config")
391 {
392 let config: WitnessConfig = serde_json::from_value(wc.clone())?;
393 return Ok(config);
394 }
395 Ok(WitnessConfig::default())
396}
397
398fn save_witness_config(repo_path: &Path, config: &WitnessConfig) -> Result<()> {
400 let storage = RegistryIdentityStorage::new(repo_path.to_path_buf());
401 let mut identity = storage.load_identity()?;
402
403 let metadata = identity
404 .metadata
405 .get_or_insert_with(|| serde_json::json!({}));
406 if let Some(obj) = metadata.as_object_mut() {
407 obj.insert("witness_config".to_string(), serde_json::to_value(config)?);
408 }
409
410 storage.create_identity(identity.controller_did.as_str(), identity.metadata)?;
411 Ok(())
412}
413
414#[cfg(feature = "witness-node")]
423mod node {
424 use std::path::PathBuf;
425 use std::time::Duration;
426
427 use std::io::Read;
428
429 use anyhow::{Result, anyhow};
430 use auths_witness_node::{
431 BuildAttestation, DockerEngine, HttpFetch, KeyCustody, NodeBuildVerdict,
432 OfflineReceiptVerdict, ReceiptBundle, SocketHealthCheck, SocketHttpFetch, StandupRequest,
433 stand_up, tear_down,
434 };
435
436 const HEALTH_WAIT: Duration = Duration::from_secs(540);
441
442 fn plan(
445 port: u16,
446 data_dir: PathBuf,
447 accept_file_key: bool,
448 image: Option<String>,
449 build_attestation: Option<PathBuf>,
450 ) -> StandupRequest {
451 let mut req = StandupRequest::local(data_dir);
452 req.host_port = port;
453 if accept_file_key {
456 req.custody = KeyCustody::File;
457 }
458 if let Some(reference) = image {
459 req.image.reference = reference;
460 }
461 req.build_attestation = build_attestation;
462 req
463 }
464
465 pub fn up(
466 port: u16,
467 data_dir: PathBuf,
468 accept_file_key: bool,
469 image: Option<String>,
470 build_attestation: Option<PathBuf>,
471 ) -> Result<()> {
472 let req = plan(port, data_dir, accept_file_key, image, build_attestation);
473 let outcome = stand_up(&req, &DockerEngine, &SocketHealthCheck, HEALTH_WAIT)
478 .map_err(|e| anyhow!("{e}"))?;
479 println!("health: {}", outcome.health_url);
480 Ok(())
481 }
482
483 pub fn down(data_dir: PathBuf, port: u16) -> Result<()> {
484 tear_down(&data_dir, port, &DockerEngine).map_err(|e| anyhow!("{e}"))?;
485 println!("witness node torn down");
486 Ok(())
487 }
488
489 pub fn status(port: u16) -> Result<()> {
490 use auths_witness_node::HealthCheck;
491 let health_url = format!("http://127.0.0.1:{port}/health");
492 if !SocketHealthCheck.is_healthy(&health_url) {
493 return Err(anyhow!(
494 "no node answering at {health_url} — is one stood up on port {port}?"
495 ));
496 }
497 println!("healthy: {health_url}");
498
499 let build_url = format!("http://127.0.0.1:{port}/build");
506 let response = SocketHttpFetch
507 .get(&build_url)
508 .map_err(|e| anyhow!("could not read the node's build proof: {e}"))?;
509 if !response.ok {
510 return Err(anyhow!(
511 "this node does not prove which binary it runs (no build attestation at \
512 {build_url}) — refuse to trust a node that cannot be vouched for"
513 ));
514 }
515 let build = BuildAttestation::from_json(&response.body)
516 .map_err(|e| anyhow!("the node's build proof is unreadable: {e}"))?;
517
518 let rt = tokio::runtime::Runtime::new()?;
519 match rt.block_on(build.verify()) {
520 verdict @ NodeBuildVerdict::Trusted { .. } => {
521 println!("{}", verdict.summary());
522 Ok(())
523 }
524 verdict => Err(anyhow!("{}", verdict.summary())),
525 }
526 }
527
528 pub fn verify_receipt(receipt: PathBuf) -> Result<()> {
535 let bytes = if receipt.as_os_str() == "-" {
536 let mut buf = Vec::new();
537 std::io::stdin()
538 .read_to_end(&mut buf)
539 .map_err(|e| anyhow!("could not read the receipt bundle from stdin: {e}"))?;
540 buf
541 } else {
542 std::fs::read(&receipt).map_err(|e| {
543 anyhow!(
544 "could not read the receipt bundle at {}: {e}",
545 receipt.display()
546 )
547 })?
548 };
549
550 let bundle = ReceiptBundle::from_json(&bytes)
551 .map_err(|e| anyhow!("the receipt bundle is not a readable receipt: {e}"))?;
552
553 match bundle.verify_offline() {
554 OfflineReceiptVerdict::Verified { witness } => {
555 println!("verified: this receipt was issued by {witness}");
556 Ok(())
557 }
558 OfflineReceiptVerdict::SignatureFailed { witness } => Err(anyhow!(
559 "rejected: this receipt does not verify against {witness} — \
560 it was altered or was not issued by that node"
561 )),
562 OfflineReceiptVerdict::UnreadableIdentity { reason } => Err(anyhow!(
563 "rejected: the witness identity in the bundle is unreadable: {reason}"
564 )),
565 }
566 }
567
568 pub fn register(endpoint: String) -> Result<()> {
569 println!("opening signed registration for {endpoint}");
570 Ok(())
571 }
572
573 pub fn logs(data_dir: PathBuf) -> Result<()> {
574 println!("streaming logs for witness node at {}", data_dir.display());
575 Ok(())
576 }
577}
578
579#[cfg(not(feature = "witness-node"))]
583mod node {
584 use std::path::PathBuf;
585
586 use anyhow::{Result, anyhow};
587
588 fn unavailable(verb: &str) -> Result<()> {
589 Err(anyhow!(
590 "`auths witness {verb}` needs the witness build; install it with \
591 `cargo install auths --features witness-node` (or use the \
592 witness-node release binary)"
593 ))
594 }
595
596 pub fn up(
597 _port: u16,
598 _data_dir: PathBuf,
599 _accept_file_key: bool,
600 _image: Option<String>,
601 _build_attestation: Option<PathBuf>,
602 ) -> Result<()> {
603 unavailable("up")
604 }
605 pub fn down(_data_dir: PathBuf, _port: u16) -> Result<()> {
606 unavailable("down")
607 }
608 pub fn status(_port: u16) -> Result<()> {
609 unavailable("status")
610 }
611 pub fn verify_receipt(_receipt: PathBuf) -> Result<()> {
612 unavailable("verify-receipt")
613 }
614 pub fn register(_endpoint: String) -> Result<()> {
615 unavailable("register")
616 }
617 pub fn logs(_data_dir: PathBuf) -> Result<()> {
618 unavailable("logs")
619 }
620}
621
622impl crate::commands::executable::ExecutableCommand for WitnessCommand {
623 fn execute(&self, ctx: &crate::config::CliConfig) -> anyhow::Result<()> {
624 handle_witness(self.clone(), ctx.repo_path.clone())
625 }
626}