Skip to main content

auths_cli/commands/
witness.rs

1//! Witness server and client management commands.
2
3use 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/// Manage identity witness servers.
23#[derive(Parser, Debug, Clone)]
24pub struct WitnessCommand {
25    #[command(subcommand)]
26    pub subcommand: WitnessSubcommand,
27}
28
29/// Witness subcommands.
30#[derive(Subcommand, Debug, Clone)]
31pub enum WitnessSubcommand {
32    /// Stand up a witness node (and its monitor) from a clean box, one command.
33    ///
34    /// Brings up the node via the embedded standup manifest, mints the node
35    /// identity at first boot, and prints a health URL. The node runs the
36    /// released, attested witness image — never a source build.
37    Up {
38        /// Host port to publish the node's endpoint on.
39        #[clap(long, default_value_t = 3333)]
40        port: u16,
41
42        /// Host directory for the node's persistent data volume.
43        #[clap(long, default_value = "./witness-data")]
44        data_dir: PathBuf,
45
46        /// Acknowledge file-backed key custody when no managed key store
47        /// (KMS/enclave) is available. Without this, a node refuses to fall
48        /// back to a file key rather than silently weaken custody.
49        #[clap(long)]
50        accept_file_key: bool,
51
52        /// Override the node image to run. Defaults to the released, attested
53        /// image the platform ships. Use this only to pin a specific released
54        /// tag or to run an image already present on an air-gapped host.
55        #[clap(long)]
56        image: Option<String>,
57
58        /// Path to the released image's signed build attestation (`auths
59        /// artifact sign` output). When supplied, the node serves a proof of
60        /// which binary it runs, and `status` verifies it. Operators pin the
61        /// attestation that ships with the released image.
62        #[clap(long)]
63        build_attestation: Option<PathBuf>,
64    },
65
66    /// Tear a stood-up witness node down.
67    Down {
68        /// Host directory of the node to tear down.
69        #[clap(long, default_value = "./witness-data")]
70        data_dir: PathBuf,
71
72        /// Host port the node to tear down was published on.
73        #[clap(long, default_value_t = 3333)]
74        port: u16,
75    },
76
77    /// Report a stood-up node's health, identity, receipts, and peers.
78    Status {
79        /// Host port the node publishes its endpoint on.
80        #[clap(long, default_value_t = 3333)]
81        port: u16,
82    },
83
84    /// Verify a witness receipt offline, on this machine alone.
85    ///
86    /// Reads a receipt bundle (a witness's signed receipt paired with the
87    /// witness's published identity) and checks it with no network and no
88    /// registry — everything needed is in the bundle. Exits non-zero, with a
89    /// distinct reason, if the receipt does not verify (a tampered or foreign
90    /// receipt). This is how a third party who does not trust the node confirms
91    /// a receipt is genuine corroboration.
92    #[command(name = "verify-receipt")]
93    VerifyReceipt {
94        /// Path to the receipt bundle JSON file (`-` reads from stdin).
95        #[clap(long)]
96        receipt: PathBuf,
97    },
98
99    /// Open a signed candidate entry to register this node in the directory.
100    Register {
101        /// Public base URL operators will reach this node at.
102        #[clap(long)]
103        endpoint: String,
104    },
105
106    /// Stream a stood-up node's logs.
107    Logs {
108        /// Host directory of the node whose logs to show.
109        #[clap(long, default_value = "./witness-data")]
110        data_dir: PathBuf,
111    },
112
113    /// Start the witness HTTP server.
114    #[command(visible_alias = "serve")]
115    Start {
116        /// Address to bind to (e.g., "127.0.0.1:3333").
117        #[clap(long, default_value = "127.0.0.1:3333")]
118        bind: SocketAddr,
119
120        /// Path to the SQLite database for witness storage.
121        #[clap(long, default_value = "witness.db")]
122        db_path: PathBuf,
123
124        /// Path to the persisted witness signing-key keystore. The advertised AID
125        /// derives from this key and is stable across restarts. Without it the
126        /// witness runs with an EPHEMERAL (unpinnable) identity. The
127        /// `AUTHS_WITNESS_SEED` env var (hex seed) takes precedence for containers.
128        #[clap(long, visible_alias = "id")]
129        identity: Option<PathBuf>,
130
131        /// Create the keystore at `--identity` if it does not exist. Without this,
132        /// a missing keystore fails closed (never silently mints a fresh key).
133        #[clap(long)]
134        generate: bool,
135
136        /// Signing curve for a newly generated identity: "p256" (default) or "ed25519".
137        #[clap(long, default_value = "p256")]
138        curve: String,
139    },
140
141    /// Add a witness URL to the identity configuration.
142    Add {
143        /// Witness server URL (e.g., "http://127.0.0.1:3333").
144        #[clap(long)]
145        url: String,
146    },
147
148    /// Remove a witness URL from the identity configuration.
149    Remove {
150        /// Witness server URL to remove.
151        #[clap(long)]
152        url: String,
153    },
154
155    /// List configured witnesses for the current identity.
156    List,
157}
158
159/// Parse the `--curve` argument into a `CurveType`.
160fn 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
170/// Resolve the witness signing identity and build the server config.
171///
172/// Precedence: `AUTHS_WITNESS_SEED` env (container injection) → `--identity`
173/// keystore (load, or create with `--generate`) → ephemeral (warned). A missing
174/// `--identity` keystore without `--generate` fails closed — it never mints a
175/// fresh key behind a path the operator meant to be stable.
176fn 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    // Boundary: the CLI is where deployment env is read. A container/binary can
184    // inject the witness signing seed here instead of mounting a keystore file.
185    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
223/// Handle witness commands.
224pub 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            // A witness is its AID, not its URL: resolve the witness's identity
274            // from its `/health` and pin `(url, aid)`. The AID is what gets
275            // designated in `b[]` and what receipt signatures are verified
276            // against. Refuse to pin a witness we can't identify.
277            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                // Independence attributes are populated in the witness config
297                // (operator/org/jurisdiction/infrastructure); untagged ⇒ the
298                // independence gate fails closed for this witness.
299                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            // Adjust threshold if needed
328            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            // Honest current truth — the single shared ceiling, never re-derived.
360            // Equivocation detection is `Sampled` until the W.3 gossip layer lands.
361            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
373/// Resolve the identity repo path (defaults to ~/.auths).
374///
375/// Expands leading `~/` so paths from clap defaults work correctly.
376fn 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
384/// Load witness config from identity metadata.
385fn 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
398/// Save witness config into identity metadata.
399fn 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/// Node-operator verbs (`up`/`down`/`status`/`register`/`logs`).
415///
416/// The clap *surface* for these verbs is always compiled in (above), so the
417/// help and argument parsing are identical in every build. The *handlers* are
418/// feature-split: a witness-enabled build runs the node via
419/// `auths-witness-node`; a lean default build returns a one-line error pointing
420/// the operator at the witness build, so the heavy node dependency stays out of
421/// the default install.
422#[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    /// How long to wait for a freshly stood-up node to answer its health
437    /// endpoint before declaring the standup failed. The cold-start budget is
438    /// generous: pulling the image and booting the node can take minutes on a
439    /// clean box; standing it up and reporting healthy is one command's job.
440    const HEALTH_WAIT: Duration = Duration::from_secs(540);
441
442    /// Build the parsed standup intent from operator arguments, failing closed
443    /// on an unacknowledged file-key downgrade.
444    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        // Managed custody is the default; a file key is a deliberate downgrade
454        // the operator must acknowledge — never silent.
455        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        // Bring the node (and its monitor sidecar) up for real, then wait until
474        // it answers its health endpoint. Success is a node answering — not the
475        // command merely returning. A failure tears down whatever started and
476        // surfaces one actionable line; nothing is left half-standing.
477        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        // Prove which binary the node runs. The node serves a signed build
500        // attestation paired with its own self-measurement; `status` confirms
501        // the signature holds AND attests the digest the node measured of
502        // itself. A node that cannot prove its binary, or whose attestation is
503        // for a different binary, fails closed here — an operator vouching for
504        // the network must itself be vouchable.
505        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    /// Verify a receipt bundle offline — the third-party corroboration check.
529    ///
530    /// Reads the bundle (file path or `-` for stdin), then decides from its
531    /// bytes alone: no network, no registry, no node need be running. A receipt
532    /// that does not verify is a non-zero exit carrying the distinct reason, so
533    /// a tampered or foreign receipt is rejected loudly, never accepted as data.
534    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/// Lean-default handlers: the node verbs parse and help identically, but a
580/// build without the witness feature cannot run a node — it returns a single
581/// actionable line instead of pulling the node dependency.
582#[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}