Skip to main content

auths_cli/commands/
trust.rs

1//! Trust management commands for Auths.
2//!
3//! Manage pinned identity roots for trust-on-first-use (TOFU) and explicit trust.
4
5use crate::ux::format::{JsonResponse, Output, is_json_mode};
6use anyhow::{Context, Result, anyhow};
7use auths_sdk::trust::{PinnedIdentity, PinnedIdentityStore, TrustLevel};
8use auths_verifier::PublicKeyHex;
9use chrono::{DateTime, Utc};
10use clap::{Parser, Subcommand};
11use serde::Serialize;
12use std::path::PathBuf;
13
14/// Manage trusted identity roots.
15#[derive(Parser, Debug, Clone)]
16#[command(
17    name = "trust",
18    about = "Pin identities you trust for verification",
19    after_help = "Examples:
20  auths trust list          # Show all pinned trusted identities
21  auths trust pin --did did:keri:EExample
22                            # Pin an identity (key resolved from its local KEL)
23  auths trust pin --did did:keri:EExample --bundle their-bundle.json
24                            # Pin from an exported identity bundle
25  auths trust remove did:keri:EExample
26                            # Remove a pinned identity
27  auths trust show did:keri:EExample
28                            # Show details of a trusted identity
29
30Related:
31  auths verify  — Verify signatures (uses trust store)
32  auths sign    — Create signatures
33  auths error   — Troubleshoot trust policy errors"
34)]
35pub struct TrustCommand {
36    #[command(subcommand)]
37    pub command: TrustSubcommand,
38}
39
40#[derive(Subcommand, Debug, Clone)]
41pub enum TrustSubcommand {
42    /// List all pinned identities.
43    List(TrustListCommand),
44
45    /// Manually pin an identity as trusted.
46    Pin(TrustPinCommand),
47
48    /// Remove a pinned identity.
49    Remove(TrustRemoveCommand),
50
51    /// Show details of a pinned identity.
52    Show(TrustShowCommand),
53}
54
55/// List all pinned identities.
56#[derive(Parser, Debug, Clone)]
57pub struct TrustListCommand {}
58
59/// Manually pin an identity as trusted.
60#[derive(Parser, Debug, Clone)]
61pub struct TrustPinCommand {
62    /// The DID of the identity to pin (e.g., did:keri:E...).
63    #[clap(long, required = true)]
64    pub did: String,
65
66    /// The public key in hex format. Omit it to resolve the current key from
67    /// the identity's locally-replayed KEL (air-gapped ceremony is the only
68    /// case that needs the explicit hex).
69    #[clap(long)]
70    pub key: Option<String>,
71
72    /// Path to an identity bundle JSON to resolve the key from (alternative to
73    /// --key and to local KEL resolution).
74    #[clap(long)]
75    pub bundle: Option<std::path::PathBuf>,
76
77    /// Identity log checkpoint for tracking key changes (optional, advanced).
78    #[clap(long)]
79    pub kel_tip: Option<String>,
80
81    /// Optional note about this identity.
82    #[clap(long)]
83    pub note: Option<String>,
84}
85
86/// Remove a pinned identity.
87#[derive(Parser, Debug, Clone)]
88pub struct TrustRemoveCommand {
89    /// The DID of the identity to remove.
90    pub did: String,
91}
92
93/// Show details of a pinned identity.
94#[derive(Parser, Debug, Clone)]
95pub struct TrustShowCommand {
96    /// The DID of the identity to show.
97    pub did: String,
98}
99
100/// JSON output for pin/remove action result.
101#[derive(Debug, Serialize)]
102struct TrustActionResult {
103    did: String,
104}
105
106/// JSON output for list command.
107#[derive(Debug, Serialize)]
108struct PinListOutput {
109    pins: Vec<PinSummary>,
110}
111
112/// Summary of a pinned identity for list output.
113#[derive(Debug, Serialize)]
114struct PinSummary {
115    did: String,
116    trust_level: String,
117    first_seen: String,
118    #[serde(skip_serializing_if = "Option::is_none")]
119    kel_sequence: Option<u128>,
120}
121
122/// JSON output for show command.
123#[derive(Debug, Serialize)]
124struct PinDetails {
125    did: String,
126    public_key_hex: PublicKeyHex,
127    trust_level: String,
128    first_seen: String,
129    origin: String,
130    #[serde(skip_serializing_if = "Option::is_none")]
131    kel_tip_said: Option<String>,
132    #[serde(skip_serializing_if = "Option::is_none")]
133    kel_sequence: Option<u128>,
134}
135
136/// Resolve the pinned-identity store for the active registry, honoring `--repo`.
137///
138/// Args:
139/// * `repo`: The optional `--repo` override; `None` selects the default `~/.auths` registry.
140///
141/// Usage:
142/// ```ignore
143/// let store = pinned_store(ctx.repo_path.clone())?;
144/// ```
145fn pinned_store(repo: Option<PathBuf>) -> Result<PinnedIdentityStore> {
146    let registry = auths_sdk::storage_layout::resolve_repo_path(repo)
147        .context("Failed to resolve the repository path for the trust store")?;
148    let default = PinnedIdentityStore::default_path();
149    let file_name = default
150        .file_name()
151        .ok_or_else(|| anyhow!("pin store path has no file name"))?;
152    Ok(PinnedIdentityStore::new(registry.join(file_name)))
153}
154
155/// Handle trust subcommands.
156#[allow(clippy::disallowed_methods)]
157pub fn handle_trust(cmd: TrustCommand, repo: Option<PathBuf>) -> Result<()> {
158    let store = pinned_store(repo)?;
159    let now = Utc::now();
160    match cmd.command {
161        TrustSubcommand::List(list_cmd) => handle_list(list_cmd, &store),
162        TrustSubcommand::Pin(pin_cmd) => handle_pin(pin_cmd, &store, now),
163        TrustSubcommand::Remove(remove_cmd) => handle_remove(remove_cmd, &store),
164        TrustSubcommand::Show(show_cmd) => handle_show(show_cmd, &store),
165    }
166}
167
168fn handle_list(_cmd: TrustListCommand, store: &PinnedIdentityStore) -> Result<()> {
169    let pins = store.list()?;
170
171    if is_json_mode() {
172        JsonResponse::success(
173            "trust list",
174            PinListOutput {
175                pins: pins
176                    .iter()
177                    .map(|p| PinSummary {
178                        did: p.did.clone(),
179                        trust_level: format!("{:?}", p.trust_level),
180                        first_seen: p.first_seen.to_rfc3339(),
181                        kel_sequence: p.kel_sequence,
182                    })
183                    .collect(),
184            },
185        )
186        .print()?;
187    } else {
188        let out = Output::new();
189        if pins.is_empty() {
190            out.println(&out.dim("No pinned identities."));
191            out.println("");
192            out.println("Use 'auths trust pin --did <DID> --key <HEX>' to pin an identity.");
193        } else {
194            out.println(&format!("{} pinned identities:", pins.len()));
195            out.println("");
196            for pin in &pins {
197                let level = match pin.trust_level {
198                    TrustLevel::Tofu => out.dim("TOFU"),
199                    TrustLevel::Manual => out.info("Manual"),
200                    TrustLevel::OrgPolicy => out.success("OrgPolicy"),
201                };
202                out.println(&format!("  {} [{}]", pin.did, level));
203            }
204        }
205    }
206
207    Ok(())
208}
209
210/// Resolve the current signing key for `did` from the local registry.
211///
212/// Returns `Ok(Some(..))` when the DID's KEL is locally held and replays to a
213/// current key, `Ok(None)` when no KEL for the DID exists locally (the
214/// air-gapped case), and `Err(..)` for any other resolution failure (malformed
215/// DID, corrupt KEL, backend fault) — those are not treated as "absent".
216fn resolve_local_current_key(did: &str) -> Result<Option<(PublicKeyHex, auths_crypto::CurveType)>> {
217    let auths_home = auths_sdk::paths::auths_home().map_err(|e| anyhow!(e))?;
218    let registry = auths_sdk::storage::GitRegistryBackend::from_config_unchecked(
219        auths_sdk::storage::RegistryConfig::single_tenant(&auths_home),
220    );
221    match auths_sdk::keri::resolve_current_public_key(&registry, did) {
222        Ok((pk, curve)) => {
223            #[allow(clippy::disallowed_methods)] // INVARIANT: hex::encode always produces valid hex
224            let hex_key = PublicKeyHex::new_unchecked(hex::encode(pk));
225            Ok(Some((hex_key, curve)))
226        }
227        Err(err) if is_kel_not_found(&err) => Ok(None),
228        Err(e) => Err(anyhow!(e)).with_context(|| {
229            format!("Could not resolve the current key for {did} from the local registry")
230        }),
231    }
232}
233
234/// Whether a current-key resolution failure means "no local KEL for this DID"
235/// as opposed to a real fault (malformed DID, corrupt KEL, backend error).
236///
237/// The not-found case carries `KelResolveError::NotFound`, which renders as
238/// `"KEL not found for <id>"` from both the local-registry collector and the
239/// resolver chain. The rendered message is matched here because the inner error
240/// type is not re-exported on the CLI's dependency path; every other failure
241/// renders differently and is treated as a real fault, so the explicit `--key`
242/// air-gap allowance is taken only for a genuine absence.
243fn is_kel_not_found(err: &auths_sdk::keri::CurrentKeyError) -> bool {
244    err.to_string().contains("KEL not found for")
245}
246
247/// Resolve the key material for a pin: explicit `--key` hex, a `--bundle`
248/// file, or the identity's locally-replayed KEL — in that order. Humans never
249/// have to produce raw hex on the happy path.
250///
251/// An explicit `--key` is cross-checked against the DID's current key in the
252/// local key history (KEL) when that history is available: pinning a key the
253/// identity does not control is refused. The explicit key is honored without a
254/// cross-check only when no local KEL for the DID exists, which is the
255/// air-gapped ceremony case.
256fn resolve_pin_key(cmd: &TrustPinCommand) -> Result<(PublicKeyHex, auths_crypto::CurveType)> {
257    if let Some(ref key_hex) = cmd.key {
258        let public_key_hex = PublicKeyHex::parse(key_hex).context("Invalid public key hex")?;
259        let curve = auths_crypto::did_key_decode(&cmd.did)
260            .map(|d| d.curve())
261            .unwrap_or_default();
262        if let Some((kel_key, kel_curve)) = resolve_local_current_key(&cmd.did)? {
263            if kel_key != public_key_hex {
264                anyhow::bail!(
265                    "the supplied --key does not match the current key in {}'s key history \
266                     (KEL); refusing to pin a key the identity does not control. Omit --key to \
267                     pin the KEL-resolved key, or use --bundle.",
268                    cmd.did
269                );
270            }
271            return Ok((kel_key, kel_curve));
272        }
273        return Ok((public_key_hex, curve));
274    }
275    if let Some(ref bundle_path) = cmd.bundle {
276        let content = std::fs::read_to_string(bundle_path)
277            .with_context(|| format!("Failed to read identity bundle: {bundle_path:?}"))?;
278        let bundle: auths_verifier::IdentityBundle = serde_json::from_str(&content)
279            .with_context(|| format!("Failed to parse identity bundle: {bundle_path:?}"))?;
280        if bundle.identity_did.as_str() != cmd.did {
281            anyhow::bail!(
282                "Bundle is for {} but --did is {}",
283                bundle.identity_did.as_str(),
284                cmd.did
285            );
286        }
287        return Ok((bundle.public_key_hex.clone(), bundle.curve));
288    }
289    let (key, curve) = resolve_local_current_key(&cmd.did)?.ok_or_else(|| {
290        anyhow!(
291            "Could not resolve {} from the local registry. Provide --bundle <file> \
292             (ask the identity owner for `auths id export-bundle`) or --key <hex>.",
293            cmd.did
294        )
295    })?;
296    Ok((key, curve))
297}
298
299fn handle_pin(cmd: TrustPinCommand, store: &PinnedIdentityStore, now: DateTime<Utc>) -> Result<()> {
300    let (public_key_hex, curve) = resolve_pin_key(&cmd)?;
301
302    // Check if already pinned
303    if let Some(existing) = store.lookup(&cmd.did)? {
304        anyhow::bail!(
305            "Identity {} is already pinned (first seen: {}). Use 'auths trust remove {}' first.",
306            cmd.did,
307            existing.first_seen.format("%Y-%m-%d"),
308            cmd.did
309        );
310    }
311
312    let pin = PinnedIdentity {
313        did: cmd.did.clone(),
314        public_key_hex: public_key_hex.clone(),
315        curve,
316        kel_tip_said: cmd.kel_tip,
317        kel_sequence: None,
318        first_seen: now,
319        origin: cmd.note.unwrap_or_else(|| "manual".to_string()),
320        trust_level: TrustLevel::Manual,
321    };
322
323    store.pin(pin)?;
324
325    if is_json_mode() {
326        JsonResponse::success(
327            "trust pin",
328            TrustActionResult {
329                did: cmd.did.clone(),
330            },
331        )
332        .print()?;
333    } else {
334        let out = Output::new();
335        out.println(&format!(
336            "{} Pinned identity: {}",
337            out.success("OK"),
338            &cmd.did
339        ));
340    }
341
342    Ok(())
343}
344
345fn handle_remove(cmd: TrustRemoveCommand, store: &PinnedIdentityStore) -> Result<()> {
346    // Check if exists
347    if store.lookup(&cmd.did)?.is_none() {
348        anyhow::bail!(
349            "Identity {} is not pinned. Pin it first with: auths trust pin {}",
350            cmd.did,
351            cmd.did
352        );
353    }
354
355    store.remove(&cmd.did)?;
356
357    if is_json_mode() {
358        JsonResponse::success(
359            "trust remove",
360            TrustActionResult {
361                did: cmd.did.clone(),
362            },
363        )
364        .print()?;
365    } else {
366        let out = Output::new();
367        out.println(&format!(
368            "{} Removed pin for: {}",
369            out.success("OK"),
370            &cmd.did
371        ));
372    }
373
374    Ok(())
375}
376
377fn handle_show(cmd: TrustShowCommand, store: &PinnedIdentityStore) -> Result<()> {
378    let pin = store.lookup(&cmd.did)?.ok_or_else(|| {
379        anyhow!(
380            "Identity {} is not pinned. Pin it first with: auths trust pin {}",
381            cmd.did,
382            cmd.did
383        )
384    })?;
385
386    if is_json_mode() {
387        JsonResponse::success(
388            "trust show",
389            PinDetails {
390                did: pin.did.clone(),
391                public_key_hex: pin.public_key_hex.clone(),
392                trust_level: format!("{:?}", pin.trust_level),
393                first_seen: pin.first_seen.to_rfc3339(),
394                origin: pin.origin.clone(),
395                kel_tip_said: pin.kel_tip_said.clone(),
396                kel_sequence: pin.kel_sequence,
397            },
398        )
399        .print()?;
400    } else {
401        let out = Output::new();
402        out.println(&format!("DID:          {}", pin.did));
403        out.println(&format!("Public Key:   {}", pin.public_key_hex));
404        out.println(&format!("Trust Level:  {:?}", pin.trust_level));
405        out.println(&format!(
406            "First Seen:   {}",
407            pin.first_seen.format("%Y-%m-%d %H:%M:%S UTC")
408        ));
409        out.println(&format!("Origin:       {}", pin.origin));
410        if let Some(ref tip) = pin.kel_tip_said {
411            out.println(&format!("Log checkpoint: {}", tip));
412        }
413        if let Some(seq) = pin.kel_sequence {
414            out.println(&format!("Log sequence:   {}", seq));
415        }
416    }
417
418    Ok(())
419}
420
421use crate::commands::executable::ExecutableCommand;
422use crate::config::CliConfig;
423
424impl ExecutableCommand for TrustCommand {
425    fn execute(&self, ctx: &CliConfig) -> Result<()> {
426        handle_trust(self.clone(), ctx.repo_path.clone())
427    }
428}