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::{Result, anyhow};
7use auths_core::trust::{PinnedIdentity, PinnedIdentityStore, TrustLevel};
8use chrono::Utc;
9use clap::{Parser, Subcommand};
10use serde::Serialize;
11
12/// Manage trusted identity roots.
13#[derive(Parser, Debug, Clone)]
14#[command(name = "trust", about = "Manage trusted identity roots")]
15pub struct TrustCommand {
16    #[command(subcommand)]
17    pub command: TrustSubcommand,
18}
19
20#[derive(Subcommand, Debug, Clone)]
21pub enum TrustSubcommand {
22    /// List all pinned identities.
23    List(TrustListCommand),
24
25    /// Manually pin an identity as trusted.
26    Pin(TrustPinCommand),
27
28    /// Remove a pinned identity.
29    Remove(TrustRemoveCommand),
30
31    /// Show details of a pinned identity.
32    Show(TrustShowCommand),
33}
34
35/// List all pinned identities.
36#[derive(Parser, Debug, Clone)]
37pub struct TrustListCommand {}
38
39/// Manually pin an identity as trusted.
40#[derive(Parser, Debug, Clone)]
41pub struct TrustPinCommand {
42    /// The DID of the identity to pin (e.g., did:keri:E...).
43    #[clap(long, required = true)]
44    pub did: String,
45
46    /// The public key in hex format (64 chars for Ed25519).
47    #[clap(long, required = true)]
48    pub key: String,
49
50    /// Optional KEL tip SAID for rotation tracking.
51    #[clap(long)]
52    pub kel_tip: Option<String>,
53
54    /// Optional note about this identity.
55    #[clap(long)]
56    pub note: Option<String>,
57}
58
59/// Remove a pinned identity.
60#[derive(Parser, Debug, Clone)]
61pub struct TrustRemoveCommand {
62    /// The DID of the identity to remove.
63    pub did: String,
64}
65
66/// Show details of a pinned identity.
67#[derive(Parser, Debug, Clone)]
68pub struct TrustShowCommand {
69    /// The DID of the identity to show.
70    pub did: String,
71}
72
73/// JSON output for pin/remove action result.
74#[derive(Debug, Serialize)]
75struct TrustActionResult {
76    did: String,
77}
78
79/// JSON output for list command.
80#[derive(Debug, Serialize)]
81struct PinListOutput {
82    pins: Vec<PinSummary>,
83}
84
85/// Summary of a pinned identity for list output.
86#[derive(Debug, Serialize)]
87struct PinSummary {
88    did: String,
89    trust_level: String,
90    first_seen: String,
91    #[serde(skip_serializing_if = "Option::is_none")]
92    kel_sequence: Option<u64>,
93}
94
95/// JSON output for show command.
96#[derive(Debug, Serialize)]
97struct PinDetails {
98    did: String,
99    public_key_hex: String,
100    trust_level: String,
101    first_seen: String,
102    origin: String,
103    #[serde(skip_serializing_if = "Option::is_none")]
104    kel_tip_said: Option<String>,
105    #[serde(skip_serializing_if = "Option::is_none")]
106    kel_sequence: Option<u64>,
107}
108
109/// Handle trust subcommands.
110pub fn handle_trust(cmd: TrustCommand) -> Result<()> {
111    match cmd.command {
112        TrustSubcommand::List(list_cmd) => handle_list(list_cmd),
113        TrustSubcommand::Pin(pin_cmd) => handle_pin(pin_cmd),
114        TrustSubcommand::Remove(remove_cmd) => handle_remove(remove_cmd),
115        TrustSubcommand::Show(show_cmd) => handle_show(show_cmd),
116    }
117}
118
119fn handle_list(_cmd: TrustListCommand) -> Result<()> {
120    let store = PinnedIdentityStore::new(PinnedIdentityStore::default_path());
121    let pins = store.list()?;
122
123    if is_json_mode() {
124        JsonResponse::success(
125            "trust list",
126            PinListOutput {
127                pins: pins
128                    .iter()
129                    .map(|p| PinSummary {
130                        did: p.did.clone(),
131                        trust_level: format!("{:?}", p.trust_level),
132                        first_seen: p.first_seen.to_rfc3339(),
133                        kel_sequence: p.kel_sequence,
134                    })
135                    .collect(),
136            },
137        )
138        .print()?;
139    } else {
140        let out = Output::new();
141        if pins.is_empty() {
142            out.println(&out.dim("No pinned identities."));
143            out.println("");
144            out.println("Use 'auths trust pin --did <DID> --key <HEX>' to pin an identity.");
145        } else {
146            out.println(&format!("{} pinned identities:", pins.len()));
147            out.println("");
148            for pin in &pins {
149                let level = match pin.trust_level {
150                    TrustLevel::Tofu => out.dim("TOFU"),
151                    TrustLevel::Manual => out.info("Manual"),
152                    TrustLevel::OrgPolicy => out.success("OrgPolicy"),
153                };
154                let did_short = truncate_did(&pin.did, 50);
155                out.println(&format!("  {} [{}]", did_short, level));
156            }
157        }
158    }
159
160    Ok(())
161}
162
163fn handle_pin(cmd: TrustPinCommand) -> Result<()> {
164    // Validate hex format and length
165    let bytes = hex::decode(&cmd.key).map_err(|e| anyhow!("Invalid hex for public key: {}", e))?;
166    if bytes.len() != 32 {
167        anyhow::bail!(
168            "Invalid key length: expected 32 bytes (64 hex chars), got {} bytes",
169            bytes.len()
170        );
171    }
172
173    let store = PinnedIdentityStore::new(PinnedIdentityStore::default_path());
174
175    // Check if already pinned
176    if let Some(existing) = store.lookup(&cmd.did)? {
177        anyhow::bail!(
178            "Identity {} is already pinned (first seen: {}). Use 'auths trust remove {}' first.",
179            cmd.did,
180            existing.first_seen.format("%Y-%m-%d"),
181            cmd.did
182        );
183    }
184
185    let pin = PinnedIdentity {
186        did: cmd.did.clone(),
187        public_key_hex: cmd.key.clone(),
188        kel_tip_said: cmd.kel_tip,
189        kel_sequence: None,
190        first_seen: Utc::now(),
191        origin: cmd.note.unwrap_or_else(|| "manual".to_string()),
192        trust_level: TrustLevel::Manual,
193    };
194
195    store.pin(pin)?;
196
197    if is_json_mode() {
198        JsonResponse::success(
199            "trust pin",
200            TrustActionResult {
201                did: cmd.did.clone(),
202            },
203        )
204        .print()?;
205    } else {
206        let out = Output::new();
207        out.println(&format!(
208            "{} Pinned identity: {}",
209            out.success("OK"),
210            truncate_did(&cmd.did, 50)
211        ));
212    }
213
214    Ok(())
215}
216
217fn handle_remove(cmd: TrustRemoveCommand) -> Result<()> {
218    let store = PinnedIdentityStore::new(PinnedIdentityStore::default_path());
219
220    // Check if exists
221    if store.lookup(&cmd.did)?.is_none() {
222        anyhow::bail!("Identity {} is not pinned.", cmd.did);
223    }
224
225    store.remove(&cmd.did)?;
226
227    if is_json_mode() {
228        JsonResponse::success(
229            "trust remove",
230            TrustActionResult {
231                did: cmd.did.clone(),
232            },
233        )
234        .print()?;
235    } else {
236        let out = Output::new();
237        out.println(&format!(
238            "{} Removed pin for: {}",
239            out.success("OK"),
240            truncate_did(&cmd.did, 50)
241        ));
242    }
243
244    Ok(())
245}
246
247fn handle_show(cmd: TrustShowCommand) -> Result<()> {
248    let store = PinnedIdentityStore::new(PinnedIdentityStore::default_path());
249
250    let pin = store
251        .lookup(&cmd.did)?
252        .ok_or_else(|| anyhow!("Identity {} is not pinned.", cmd.did))?;
253
254    if is_json_mode() {
255        JsonResponse::success(
256            "trust show",
257            PinDetails {
258                did: pin.did.clone(),
259                public_key_hex: pin.public_key_hex.clone(),
260                trust_level: format!("{:?}", pin.trust_level),
261                first_seen: pin.first_seen.to_rfc3339(),
262                origin: pin.origin.clone(),
263                kel_tip_said: pin.kel_tip_said.clone(),
264                kel_sequence: pin.kel_sequence,
265            },
266        )
267        .print()?;
268    } else {
269        let out = Output::new();
270        out.println(&format!("DID:          {}", pin.did));
271        out.println(&format!("Public Key:   {}", pin.public_key_hex));
272        out.println(&format!("Trust Level:  {:?}", pin.trust_level));
273        out.println(&format!(
274            "First Seen:   {}",
275            pin.first_seen.format("%Y-%m-%d %H:%M:%S UTC")
276        ));
277        out.println(&format!("Origin:       {}", pin.origin));
278        if let Some(ref tip) = pin.kel_tip_said {
279            out.println(&format!("KEL Tip:      {}", tip));
280        }
281        if let Some(seq) = pin.kel_sequence {
282            out.println(&format!("KEL Sequence: {}", seq));
283        }
284    }
285
286    Ok(())
287}
288
289/// Truncate a DID for display.
290fn truncate_did(did: &str, max_len: usize) -> String {
291    if did.len() <= max_len {
292        did.to_string()
293    } else {
294        format!("{}...", &did[..max_len - 3])
295    }
296}
297
298use crate::commands::executable::ExecutableCommand;
299use crate::config::CliConfig;
300
301impl ExecutableCommand for TrustCommand {
302    fn execute(&self, _ctx: &CliConfig) -> Result<()> {
303        handle_trust(self.clone())
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    #[test]
312    fn test_truncate_did_short() {
313        let did = "did:keri:E123";
314        assert_eq!(truncate_did(did, 20), did);
315    }
316
317    #[test]
318    fn test_truncate_did_long() {
319        let did = "did:keri:EXq5YqaL6L48pf0fu7IUhL0JRaU2_RxFP0AL43wYn148";
320        let truncated = truncate_did(did, 24);
321        assert!(truncated.ends_with("..."));
322        assert_eq!(truncated.len(), 24);
323    }
324}