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    /// Start the witness HTTP server.
33    #[command(visible_alias = "serve")]
34    Start {
35        /// Address to bind to (e.g., "127.0.0.1:3333").
36        #[clap(long, default_value = "127.0.0.1:3333")]
37        bind: SocketAddr,
38
39        /// Path to the SQLite database for witness storage.
40        #[clap(long, default_value = "witness.db")]
41        db_path: PathBuf,
42
43        /// Path to the persisted witness signing-key keystore. The advertised AID
44        /// derives from this key and is stable across restarts. Without it the
45        /// witness runs with an EPHEMERAL (unpinnable) identity. The
46        /// `AUTHS_WITNESS_SEED` env var (hex seed) takes precedence for containers.
47        #[clap(long, visible_alias = "id")]
48        identity: Option<PathBuf>,
49
50        /// Create the keystore at `--identity` if it does not exist. Without this,
51        /// a missing keystore fails closed (never silently mints a fresh key).
52        #[clap(long)]
53        generate: bool,
54
55        /// Signing curve for a newly generated identity: "p256" (default) or "ed25519".
56        #[clap(long, default_value = "p256")]
57        curve: String,
58    },
59
60    /// Add a witness URL to the identity configuration.
61    Add {
62        /// Witness server URL (e.g., "http://127.0.0.1:3333").
63        #[clap(long)]
64        url: String,
65    },
66
67    /// Remove a witness URL from the identity configuration.
68    Remove {
69        /// Witness server URL to remove.
70        #[clap(long)]
71        url: String,
72    },
73
74    /// List configured witnesses for the current identity.
75    List,
76}
77
78/// Parse the `--curve` argument into a `CurveType`.
79fn parse_curve_arg(curve: &str) -> Result<auths_crypto::CurveType> {
80    match curve {
81        "p256" | "P256" => Ok(auths_crypto::CurveType::P256),
82        "ed25519" | "Ed25519" => Ok(auths_crypto::CurveType::Ed25519),
83        other => Err(anyhow!(
84            "unknown --curve '{other}'; expected 'p256' or 'ed25519'"
85        )),
86    }
87}
88
89/// Resolve the witness signing identity and build the server config.
90///
91/// Precedence: `AUTHS_WITNESS_SEED` env (container injection) → `--identity`
92/// keystore (load, or create with `--generate`) → ephemeral (warned). A missing
93/// `--identity` keystore without `--generate` fails closed — it never mints a
94/// fresh key behind a path the operator meant to be stable.
95fn build_witness_config(
96    db_path: PathBuf,
97    identity: Option<PathBuf>,
98    generate: bool,
99    curve: auths_crypto::CurveType,
100) -> Result<WitnessServerConfig> {
101    #[allow(clippy::disallowed_methods)]
102    // Boundary: the CLI is where deployment env is read. A container/binary can
103    // inject the witness signing seed here instead of mounting a keystore file.
104    let env_seed = std::env::var("AUTHS_WITNESS_SEED").ok();
105
106    if let Some(seed_hex) = env_seed {
107        let signer = witness_signer_from_seed_hex(curve, &seed_hex)
108            .map_err(|e| anyhow!("invalid AUTHS_WITNESS_SEED: {e}"))?;
109        return WitnessServerConfig::from_signer(db_path, signer)
110            .map_err(|e| anyhow!("witness config from injected seed: {e}"));
111    }
112
113    if let Some(identity_path) = identity {
114        let path =
115            expand_tilde(&identity_path).map_err(|e| anyhow!("invalid --identity path: {e}"))?;
116        let signer = if path.exists() {
117            load_witness_signer(&path).map_err(|e| anyhow!("{e}"))?
118        } else if generate {
119            let signer =
120                generate_and_persist_witness_signer(&path, curve).map_err(|e| anyhow!("{e}"))?;
121            println!("Generated new witness identity at {}", path.display());
122            signer
123        } else {
124            return Err(anyhow!(
125                "no witness identity at {}; pass --generate to create one \
126                 (refusing to mint an ephemeral key for a --identity path)",
127                path.display()
128            ));
129        };
130        return WitnessServerConfig::from_signer(db_path, signer)
131            .map_err(|e| anyhow!("witness config: {e}"));
132    }
133
134    eprintln!(
135        "warning: starting with an EPHEMERAL witness identity (new AID each launch, \
136         not pinnable); pass --identity <path> --generate for a stable identity"
137    );
138    WitnessServerConfig::with_generated_keypair(db_path, curve)
139        .map_err(|e| anyhow!("Failed to generate witness keypair: {e}"))
140}
141
142/// Handle witness commands.
143pub fn handle_witness(cmd: WitnessCommand, repo_opt: Option<PathBuf>) -> Result<()> {
144    match cmd.subcommand {
145        WitnessSubcommand::Start {
146            bind,
147            db_path,
148            identity,
149            generate,
150            curve,
151        } => {
152            let curve = parse_curve_arg(&curve)?;
153            let cfg = build_witness_config(db_path, identity, generate, curve)?;
154            let rt = tokio::runtime::Runtime::new()?;
155            rt.block_on(async {
156                let state = WitnessServerState::new(cfg)
157                    .map_err(|e| anyhow::anyhow!("Failed to create witness state: {}", e))?;
158
159                println!(
160                    "Witness server started at {} (identity: {})",
161                    bind,
162                    state.witness_did()
163                );
164
165                run_server(state, bind)
166                    .await
167                    .map_err(|e| anyhow::anyhow!("Server error: {}", e))?;
168
169                Ok(())
170            })
171        }
172
173        WitnessSubcommand::Add { url } => {
174            let repo_path = resolve_repo_path(repo_opt)?;
175            let parsed_url: url::Url = url
176                .parse()
177                .map_err(|e| anyhow!("Invalid witness URL '{}': {}", url, e))?;
178            let mut config = load_witness_config(&repo_path)?;
179            // A witness is its AID, not its URL: resolve the witness's identity
180            // from its `/health` and pin `(url, aid)`. The AID is what gets
181            // designated in `b[]` and what receipt signatures are verified
182            // against. Refuse to pin a witness we can't identify.
183            let rt = tokio::runtime::Runtime::new()?;
184            let aid = rt
185                .block_on(async {
186                    let client = HttpAsyncWitnessClient::new(
187                        parsed_url.to_string(),
188                        config.threshold.max(1),
189                    );
190                    client.witness_aid().await
191                })
192                .map_err(|e| {
193                    anyhow!(
194                        "Could not resolve witness identity from {}/health: {}",
195                        parsed_url,
196                        e
197                    )
198                })?;
199            if !config.pin(WitnessRef {
200                url: parsed_url.clone(),
201                aid: aid.clone(),
202                // Independence attributes are populated in the witness config
203                // (operator/org/jurisdiction/infrastructure); untagged ⇒ the
204                // independence gate fails closed for this witness.
205                operator_info: None,
206            }) {
207                println!("Witness already configured (aid {}): {}", aid.as_str(), url);
208                return Ok(());
209            }
210            if config.threshold == 0 {
211                config.threshold = 1;
212            }
213            save_witness_config(&repo_path, &config)?;
214            println!("Added witness: {} (aid {})", url, aid.as_str());
215            println!(
216                "  Witnesses: {}, required: {}",
217                config.witnesses.len(),
218                config.threshold
219            );
220            Ok(())
221        }
222
223        WitnessSubcommand::Remove { url } => {
224            let repo_path = resolve_repo_path(repo_opt)?;
225            let parsed_url: url::Url = url
226                .parse()
227                .map_err(|e| anyhow!("Invalid witness URL '{}': {}", url, e))?;
228            let mut config = load_witness_config(&repo_path)?;
229            if !config.remove_url(&parsed_url) {
230                println!("Witness not found: {}", url);
231                return Ok(());
232            }
233            // Adjust threshold if needed
234            if config.threshold > config.witnesses.len() {
235                config.threshold = config.witnesses.len();
236            }
237            save_witness_config(&repo_path, &config)?;
238            println!("Removed witness: {}", url);
239            println!(
240                "  Remaining witnesses: {}, required: {}",
241                config.witnesses.len(),
242                config.threshold
243            );
244            Ok(())
245        }
246
247        WitnessSubcommand::List => {
248            let repo_path = resolve_repo_path(repo_opt)?;
249            let config = load_witness_config(&repo_path)?;
250            if config.witnesses.is_empty() {
251                println!("No witnesses configured.");
252                return Ok(());
253            }
254            println!("Configured witnesses:");
255            for (i, w) in config.witnesses.iter().enumerate() {
256                println!("  {}. {}  (aid {})", i + 1, w.url, w.aid.as_str());
257            }
258            println!(
259                "\nRequired: {}/{} (policy: {:?})",
260                config.threshold,
261                config.witnesses.len(),
262                config.policy
263            );
264
265            // Honest current truth — the single shared ceiling, never re-derived.
266            // Equivocation detection is `Sampled` until the W.3 gossip layer lands.
267            let independence = config.roster_independence(&IndependencePolicy::default());
268            let ceiling = honesty_ceiling(&independence, EquivocationDetection::Sampled);
269            let status = if ceiling.policy_met { "MET" } else { "FAILING" };
270            println!("\nIndependence: {status} — {}", ceiling.label);
271            if !ceiling.shortfalls.is_empty() {
272                println!("  shortfall: {}", ceiling.shortfalls.join(", "));
273            }
274            Ok(())
275        }
276    }
277}
278
279/// Resolve the identity repo path (defaults to ~/.auths).
280///
281/// Expands leading `~/` so paths from clap defaults work correctly.
282fn resolve_repo_path(repo_opt: Option<PathBuf>) -> Result<PathBuf> {
283    if let Some(path) = repo_opt {
284        return Ok(expand_tilde(&path)?);
285    }
286    let home = dirs::home_dir().ok_or_else(|| anyhow!("Could not determine home directory"))?;
287    Ok(home.join(".auths"))
288}
289
290/// Load witness config from identity metadata.
291fn load_witness_config(repo_path: &Path) -> Result<WitnessConfig> {
292    let storage = RegistryIdentityStorage::new(repo_path.to_path_buf());
293    let identity = storage.load_identity()?;
294
295    if let Some(ref metadata) = identity.metadata
296        && let Some(wc) = metadata.get("witness_config")
297    {
298        let config: WitnessConfig = serde_json::from_value(wc.clone())?;
299        return Ok(config);
300    }
301    Ok(WitnessConfig::default())
302}
303
304/// Save witness config into identity metadata.
305fn save_witness_config(repo_path: &Path, config: &WitnessConfig) -> Result<()> {
306    let storage = RegistryIdentityStorage::new(repo_path.to_path_buf());
307    let mut identity = storage.load_identity()?;
308
309    let metadata = identity
310        .metadata
311        .get_or_insert_with(|| serde_json::json!({}));
312    if let Some(obj) = metadata.as_object_mut() {
313        obj.insert("witness_config".to_string(), serde_json::to_value(config)?);
314    }
315
316    storage.create_identity(identity.controller_did.as_str(), identity.metadata)?;
317    Ok(())
318}
319
320impl crate::commands::executable::ExecutableCommand for WitnessCommand {
321    fn execute(&self, ctx: &crate::config::CliConfig) -> anyhow::Result<()> {
322        handle_witness(self.clone(), ctx.repo_path.clone())
323    }
324}