Skip to main content

auths_cli/commands/
credential.rs

1//! `auths credential …` — issue / revoke / list / verify capability credentials.
2//!
3//! A credential is an ACDC anchored to the issuer's KEL via a backerless TEL. This is
4//! the thin presentation layer; all orchestration (issuee guard, registry, issuer-sign,
5//! `iss`/`rev` anchor, the verify resolution + freshness layer) lives in
6//! `auths_sdk::domains::credentials`. The clock (`Utc::now()`) is read only here.
7
8use std::path::PathBuf;
9use std::sync::Arc;
10
11use anyhow::Result;
12use clap::{Parser, Subcommand};
13use serde::Serialize;
14
15use auths_rp::{Nonce, WirePresentation};
16use auths_sdk::core_config::EnvironmentConfig;
17use auths_sdk::domains::credentials::{
18    CredentialVerdict, PresentationChallenge, VerifierWitnessPolicy, issue, list,
19    present_credential, revoke, verify_by_said,
20};
21use auths_sdk::keychain::KeyAlias;
22use auths_sdk::signing::PassphraseProvider;
23use auths_sdk::storage_layout::layout;
24
25use crate::commands::executable::ExecutableCommand;
26use crate::config::CliConfig;
27use crate::factories::storage::build_auths_context;
28use crate::ux::format::{JsonResponse, is_json_mode};
29
30/// Issue, revoke, list, and verify capability credentials (ACDCs).
31#[derive(Parser, Debug, Clone)]
32#[command(
33    about = "Issue, revoke, list, and verify capability credentials (ACDCs).",
34    after_help = "Examples:
35  auths credential issue --issuer my-key --to did:keri:E… --cap sign --role deployer
36  auths credential revoke ECred… --issuer my-key
37  auths credential list --issuer my-key
38  auths credential verify ECred… --issuer my-key --require-witnesses"
39)]
40pub struct CredentialCommand {
41    #[clap(subcommand)]
42    pub subcommand: CredentialSubcommand,
43}
44
45/// Credential subcommands.
46#[derive(Subcommand, Debug, Clone)]
47pub enum CredentialSubcommand {
48    /// Issue a capability credential to an issuee (its KEL must already exist).
49    Issue {
50        /// The issuer's signing key name (your identity's key).
51        #[arg(long, help = "The issuer's signing key name (your identity's key).")]
52        issuer: String,
53
54        /// The issuee/subject `did:keri:` to credential.
55        #[arg(long = "to", help = "The issuee/subject did:keri to credential.")]
56        to: String,
57
58        /// Capability to grant (repeatable).
59        #[arg(long = "cap", help = "Capability to grant (repeatable).")]
60        cap: Vec<auths_keri::Capability>,
61
62        /// Informational role claim.
63        #[arg(long, help = "Informational role claim (e.g. deployer).")]
64        role: Option<String>,
65
66        /// Expire the credential this many seconds from now.
67        #[arg(long = "expires-in", help = "Expire the credential after N seconds.")]
68        expires_in: Option<i64>,
69    },
70
71    /// Revoke a credential (anchors a `rev` in the issuer's KEL). Idempotent.
72    Revoke {
73        /// The credential SAID to revoke.
74        #[arg(help = "The credential SAID to revoke.")]
75        credential_said: String,
76
77        /// The issuer's signing key name.
78        #[arg(long, help = "The issuer's signing key name.")]
79        issuer: String,
80    },
81
82    /// List the issuer's live credentials (issued − revoked).
83    List {
84        /// The issuer's signing key name.
85        #[arg(long, help = "The issuer's signing key name.")]
86        issuer: Option<String>,
87
88        /// Include revoked credentials in the listing.
89        #[arg(long, help = "Include revoked credentials.")]
90        include_revoked: bool,
91    },
92
93    /// Verify a credential, resolving the issuer KEL/TEL + witness receipts.
94    Verify {
95        /// The credential SAID to verify.
96        #[arg(help = "The credential SAID to verify.")]
97        credential_said: String,
98
99        /// The issuer's signing key name (whose namespace holds the credential).
100        #[arg(long, help = "The issuer's signing key name.")]
101        issuer: String,
102
103        /// Fail closed unless every lifecycle anchor reaches witness quorum.
104        #[arg(
105            long = "require-witnesses",
106            help = "Fail closed unless every lifecycle anchor reaches witness quorum."
107        )]
108        require_witnesses: bool,
109    },
110
111    /// Present a credential: prove control of the subject AID and emit an `Auths-Presentation` header.
112    Present {
113        /// The subject (holder/agent) keychain alias whose current key signs the presentation.
114        #[arg(long, help = "The subject (holder) keychain alias to sign with.")]
115        subject: String,
116
117        /// The credential SAID to present.
118        #[arg(long = "said", help = "The credential SAID to present.")]
119        said: String,
120
121        /// The relying-party audience the presentation binds to.
122        #[arg(long, help = "The relying-party audience to bind to.")]
123        audience: String,
124
125        /// The base64url challenge nonce issued by the relying party.
126        #[arg(long, help = "The base64url challenge nonce from /v1/auth/challenge.")]
127        nonce: String,
128    },
129}
130
131/// JSON response for `credential issue`.
132#[derive(Debug, Serialize)]
133struct IssueResponse {
134    credential_said: String,
135    registry_said: String,
136    issuer_did: String,
137    issuee_did: String,
138}
139
140impl ExecutableCommand for CredentialCommand {
141    fn execute(&self, ctx: &CliConfig) -> Result<()> {
142        let repo_path = layout::resolve_repo_path(ctx.repo_path.clone())?;
143        handle_credential(
144            self.clone(),
145            repo_path,
146            &ctx.env_config,
147            ctx.passphrase_provider.clone(),
148        )
149    }
150}
151
152/// Dispatch an `auths credential …` subcommand.
153///
154/// Args:
155/// * `cmd`: The parsed credential command.
156/// * `repo_path`: Resolved registry repository path.
157/// * `env_config`: Environment configuration for context building.
158/// * `passphrase_provider`: Passphrase source for issuer key access.
159///
160/// Usage:
161/// ```ignore
162/// handle_credential(cmd, repo_path, &env_config, passphrase_provider)?;
163/// ```
164pub fn handle_credential(
165    cmd: CredentialCommand,
166    repo_path: PathBuf,
167    env_config: &EnvironmentConfig,
168    passphrase_provider: Arc<dyn PassphraseProvider + Send + Sync>,
169) -> Result<()> {
170    match cmd.subcommand {
171        CredentialSubcommand::Issue {
172            issuer,
173            to,
174            cap,
175            role,
176            expires_in,
177        } => {
178            let ctx = build_auths_context(&repo_path, env_config, Some(passphrase_provider))?;
179            let issuer_alias = KeyAlias::new_unchecked(issuer);
180            // Clock at the presentation boundary (the SDK/core never call Utc::now()).
181            #[allow(clippy::disallowed_methods)]
182            let expires_at =
183                expires_in.map(|secs| chrono::Utc::now() + chrono::Duration::seconds(secs));
184            let issued = issue(&ctx, &issuer_alias, &to, &cap, role.as_deref(), expires_at)
185                .map_err(anyhow::Error::new)?;
186
187            if is_json_mode() {
188                JsonResponse::success(
189                    "credential issue",
190                    IssueResponse {
191                        credential_said: issued.credential_said.clone(),
192                        registry_said: issued.registry_said.clone(),
193                        issuer_did: issued.issuer_did.clone(),
194                        issuee_did: issued.issuee_did.clone(),
195                    },
196                )
197                .print()?;
198            } else {
199                println!("✓ Credential issued and anchored to the issuer KEL:");
200                println!("  credential: {}", issued.credential_said);
201                println!("  issuee:     {}", issued.issuee_did);
202            }
203            Ok(())
204        }
205
206        CredentialSubcommand::Present {
207            subject,
208            said,
209            audience,
210            nonce,
211        } => {
212            let ctx = build_auths_context(&repo_path, env_config, Some(passphrase_provider))?;
213            let subject_alias = KeyAlias::new_unchecked(subject);
214            let challenge_nonce = Nonce::parse_b64url(&nonce).map_err(anyhow::Error::new)?;
215            let envelope = present_credential(
216                &ctx,
217                &subject_alias,
218                &said,
219                &audience,
220                PresentationChallenge::Challenge {
221                    nonce: challenge_nonce.as_bytes().to_vec(),
222                },
223            )
224            .map_err(anyhow::Error::new)?;
225            let token = WirePresentation::from_envelope(&envelope)
226                .to_token()
227                .map_err(anyhow::Error::new)?;
228            let header = format!("Auths-Presentation {token}");
229
230            if is_json_mode() {
231                JsonResponse::success(
232                    "credential present",
233                    serde_json::json!({ "authorization": header }),
234                )
235                .print()?;
236            } else {
237                println!("{header}");
238            }
239            Ok(())
240        }
241
242        CredentialSubcommand::Revoke {
243            credential_said,
244            issuer,
245        } => {
246            let ctx = build_auths_context(&repo_path, env_config, Some(passphrase_provider))?;
247            let issuer_alias = KeyAlias::new_unchecked(issuer);
248            revoke(&ctx, &issuer_alias, &credential_said).map_err(anyhow::Error::new)?;
249
250            if is_json_mode() {
251                JsonResponse::success(
252                    "credential revoke",
253                    serde_json::json!({ "credential_said": credential_said, "revoked": true }),
254                )
255                .print()?;
256            } else {
257                println!(
258                    "✓ Credential revoked (rev anchored in the issuer KEL): {credential_said}"
259                );
260            }
261            Ok(())
262        }
263
264        CredentialSubcommand::List {
265            issuer,
266            include_revoked,
267        } => {
268            let ctx = build_auths_context(&repo_path, env_config, Some(passphrase_provider))?;
269            let issuer_alias = KeyAlias::new_unchecked(issuer.unwrap_or_default());
270            let credentials = list(&ctx, &issuer_alias).map_err(anyhow::Error::new)?;
271            let shown: Vec<_> = credentials
272                .into_iter()
273                .filter(|c| include_revoked || !c.revoked)
274                .collect();
275
276            if is_json_mode() {
277                let data: Vec<_> = shown
278                    .iter()
279                    .map(|c| {
280                        serde_json::json!({
281                            "credential_said": c.credential_said,
282                            "subject_did": c.subject_did,
283                            "capabilities": c.capabilities,
284                            "revoked": c.revoked,
285                        })
286                    })
287                    .collect();
288                JsonResponse::success(
289                    "credential list",
290                    serde_json::json!({ "credentials": data }),
291                )
292                .print()?;
293            } else if shown.is_empty() {
294                println!("No credentials issued by this identity.");
295            } else {
296                println!("Issued credentials:");
297                for c in &shown {
298                    let status = if c.revoked { " (revoked)" } else { "" };
299                    println!(
300                        "  {} → {} [{}]{}",
301                        c.credential_said,
302                        c.subject_did,
303                        c.capabilities
304                            .iter()
305                            .map(|cap| cap.as_str())
306                            .collect::<Vec<_>>()
307                            .join(","),
308                        status
309                    );
310                }
311            }
312            Ok(())
313        }
314
315        CredentialSubcommand::Verify {
316            credential_said,
317            issuer,
318            require_witnesses,
319        } => {
320            let ctx = build_auths_context(&repo_path, env_config, Some(passphrase_provider))?;
321            let issuer_alias = KeyAlias::new_unchecked(issuer);
322            let policy = if require_witnesses {
323                VerifierWitnessPolicy::RequireWitnesses
324            } else {
325                VerifierWitnessPolicy::Warn
326            };
327            // Clock at the presentation boundary.
328            #[allow(clippy::disallowed_methods)]
329            let now = chrono::Utc::now();
330            let rt = tokio::runtime::Runtime::new()?;
331            let verdict = rt
332                .block_on(verify_by_said(
333                    &ctx,
334                    &issuer_alias,
335                    &credential_said,
336                    policy,
337                    now,
338                ))
339                .map_err(anyhow::Error::new)?;
340            print_verdict(&credential_said, &verdict)
341        }
342    }
343}
344
345/// Render a verification verdict to stdout (JSON or human-readable).
346fn print_verdict(credential_said: &str, verdict: &CredentialVerdict) -> Result<()> {
347    let valid = verdict.is_valid();
348    let (status, detail, as_of) = describe(verdict);
349
350    if is_json_mode() {
351        JsonResponse::success(
352            "credential verify",
353            serde_json::json!({
354                "credential_said": credential_said,
355                "valid": valid,
356                "status": status,
357                "detail": detail,
358                "as_of": as_of,
359            }),
360        )
361        .print()?;
362    } else if valid {
363        println!("✓ Credential is valid: {credential_said}");
364        if let Some(seq) = as_of {
365            println!("  as-of issuer KEL seq {seq}");
366        }
367    } else {
368        println!("✗ Credential did not verify: {credential_said}");
369        println!("  status: {status}");
370        if let Some(d) = detail {
371            println!("  detail: {d}");
372        }
373    }
374    Ok(())
375}
376
377/// A `(status, detail, as_of_seq)` summary of a verdict for presentation.
378fn describe(verdict: &CredentialVerdict) -> (&'static str, Option<String>, Option<u128>) {
379    use auths_verifier::CredentialVerdict as Inner;
380    match verdict {
381        CredentialVerdict::StaleOrUnresolvable { as_of, reason } => (
382            "stale_or_unresolvable",
383            Some(reason.clone()),
384            Some(as_of.seq),
385        ),
386        CredentialVerdict::Resolved { verdict, as_of } => {
387            let seq = Some(as_of.seq);
388            match verdict {
389                Inner::Valid { .. } => ("valid", None, seq),
390                Inner::SaidMismatch => ("said_mismatch", None, seq),
391                Inner::SchemaInvalid => ("schema_invalid", None, seq),
392                Inner::IssuerSignatureInvalid => ("issuer_signature_invalid", None, seq),
393                Inner::RegistryNotEstablished => ("registry_not_established", None, seq),
394                Inner::CredentialRevoked { revoked_at } => (
395                    "revoked",
396                    Some(format!("revoked at issuer KEL seq {revoked_at}")),
397                    seq,
398                ),
399                Inner::Expired { expired_at, .. } => {
400                    ("expired", Some(format!("expired at {expired_at}")), seq)
401                }
402                Inner::WitnessQuorumNotMet {
403                    event,
404                    collected,
405                    required,
406                } => (
407                    "witness_quorum_not_met",
408                    Some(format!(
409                        "{event} anchor: {collected}/{required} witness receipts"
410                    )),
411                    seq,
412                ),
413                Inner::IssuerKelDuplicitous => ("issuer_kel_duplicitous", None, seq),
414            }
415        }
416    }
417}