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, UsageObservation, VerifierWitnessPolicy, issue, list,
19    present_credential, revoke, verify_by_said_with_usage,
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.
31#[derive(Parser, Debug, Clone)]
32#[command(
33    about = "Issue, revoke, list, and verify capability credentials.",
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        /// Enforce a quantitative usage cap against an observed call count.
111        ///
112        /// Path to a JSON usage observation `{"said":"…","calls_used":N}`. When the
113        /// credential carries a `calls:<N>` cap, the observed count is checked
114        /// against the verifier's monotonic usage ledger: an over-budget count fails
115        /// with `cap_exceeded`, a replayed (lower) count fails with
116        /// `usage_counter_rolled_back`. Omitted for credentials without a usage cap.
117        #[arg(
118            long = "usage-counter",
119            value_name = "FILE",
120            help = "Enforce a quantitative usage cap against an observed call count (JSON: {\"said\":…,\"calls_used\":N})."
121        )]
122        usage_counter: Option<PathBuf>,
123    },
124
125    /// Present a credential: prove control of the subject AID and emit an `Auths-Presentation` header.
126    Present {
127        /// The subject (holder/agent) keychain alias whose current key signs the presentation.
128        #[arg(long, help = "The subject (holder) keychain alias to sign with.")]
129        subject: String,
130
131        /// The credential SAID to present.
132        #[arg(long = "said", help = "The credential SAID to present.")]
133        said: String,
134
135        /// The relying-party audience the presentation binds to.
136        #[arg(long, help = "The relying-party audience to bind to.")]
137        audience: String,
138
139        /// The base64url challenge nonce issued by the relying party.
140        #[arg(
141            long,
142            allow_hyphen_values = true,
143            help = "The base64url challenge nonce from /v1/auth/challenge."
144        )]
145        nonce: String,
146
147        /// Also emit the wire-ready evidence (credential + attachment-carrying KELs + TEL)
148        /// a relying party without a registry replica verifies the presentation against.
149        #[arg(
150            long = "with-evidence",
151            help = "Emit JSON with the header AND the verifiable evidence bundle (for relying parties that hold no replica of your KEL)."
152        )]
153        with_evidence: bool,
154    },
155}
156
157/// JSON response for `credential issue`.
158#[derive(Debug, Serialize)]
159struct IssueResponse {
160    credential_said: String,
161    registry_said: String,
162    issuer_did: String,
163    issuee_did: String,
164}
165
166impl ExecutableCommand for CredentialCommand {
167    fn execute(&self, ctx: &CliConfig) -> Result<()> {
168        let repo_path = layout::resolve_repo_path(ctx.repo_path.clone())?;
169        handle_credential(
170            self.clone(),
171            repo_path,
172            &ctx.env_config,
173            ctx.passphrase_provider.clone(),
174        )
175    }
176}
177
178/// Dispatch an `auths credential …` subcommand.
179///
180/// Args:
181/// * `cmd`: The parsed credential command.
182/// * `repo_path`: Resolved registry repository path.
183/// * `env_config`: Environment configuration for context building.
184/// * `passphrase_provider`: Passphrase source for issuer key access.
185///
186/// Usage:
187/// ```ignore
188/// handle_credential(cmd, repo_path, &env_config, passphrase_provider)?;
189/// ```
190pub fn handle_credential(
191    cmd: CredentialCommand,
192    repo_path: PathBuf,
193    env_config: &EnvironmentConfig,
194    passphrase_provider: Arc<dyn PassphraseProvider + Send + Sync>,
195) -> Result<()> {
196    match cmd.subcommand {
197        CredentialSubcommand::Issue {
198            issuer,
199            to,
200            cap,
201            role,
202            expires_in,
203        } => {
204            let ctx = build_auths_context(&repo_path, env_config, Some(passphrase_provider))?;
205            let issuer_alias = KeyAlias::new_unchecked(issuer);
206            // Clock at the presentation boundary (the SDK/core never call Utc::now()).
207            #[allow(clippy::disallowed_methods)]
208            let expires_at =
209                expires_in.map(|secs| chrono::Utc::now() + chrono::Duration::seconds(secs));
210            let issued = issue(&ctx, &issuer_alias, &to, &cap, role.as_deref(), expires_at)
211                .map_err(anyhow::Error::new)?;
212
213            if is_json_mode() {
214                JsonResponse::success(
215                    "credential issue",
216                    IssueResponse {
217                        credential_said: issued.credential_said.clone(),
218                        registry_said: issued.registry_said.clone(),
219                        issuer_did: issued.issuer_did.clone(),
220                        issuee_did: issued.issuee_did.clone(),
221                    },
222                )
223                .print()?;
224            } else {
225                println!(
226                    "✓ Credential issued and recorded in the issuer's tamper-evident history:"
227                );
228                println!("  credential: {}", issued.credential_said);
229                println!(
230                    "  issuee:     {}",
231                    crate::ux::product_id(&issued.issuee_did)
232                );
233            }
234            Ok(())
235        }
236
237        CredentialSubcommand::Present {
238            subject,
239            said,
240            audience,
241            nonce,
242            with_evidence,
243        } => {
244            let ctx = build_auths_context(&repo_path, env_config, Some(passphrase_provider))?;
245            let subject_alias = KeyAlias::new_unchecked(subject);
246            let challenge_nonce = Nonce::parse_b64url(&nonce).map_err(anyhow::Error::new)?;
247            let envelope = present_credential(
248                &ctx,
249                &subject_alias,
250                &said,
251                &audience,
252                PresentationChallenge::Challenge {
253                    nonce: challenge_nonce.as_bytes().to_vec(),
254                },
255            )
256            .map_err(anyhow::Error::new)?;
257            let token = WirePresentation::from_envelope(&envelope)
258                .to_token()
259                .map_err(anyhow::Error::new)?;
260            let header = format!("Auths-Presentation {token}");
261
262            let evidence = if with_evidence {
263                Some(
264                    auths_sdk::domains::credentials::load_presentation_evidence(
265                        &ctx,
266                        &subject_alias,
267                        &said,
268                    )
269                    .map_err(anyhow::Error::new)?,
270                )
271            } else {
272                None
273            };
274
275            match evidence {
276                Some(evidence) => {
277                    // --with-evidence is machine-facing: always JSON, one object a
278                    // relying party splices into its verify request.
279                    JsonResponse::success(
280                        "credential present",
281                        serde_json::json!({ "authorization": header, "evidence": evidence }),
282                    )
283                    .print()?;
284                }
285                None if is_json_mode() => {
286                    JsonResponse::success(
287                        "credential present",
288                        serde_json::json!({ "authorization": header }),
289                    )
290                    .print()?;
291                }
292                None => println!("{header}"),
293            }
294            Ok(())
295        }
296
297        CredentialSubcommand::Revoke {
298            credential_said,
299            issuer,
300        } => {
301            let ctx = build_auths_context(&repo_path, env_config, Some(passphrase_provider))?;
302            let issuer_alias = KeyAlias::new_unchecked(issuer);
303            revoke(&ctx, &issuer_alias, &credential_said).map_err(anyhow::Error::new)?;
304
305            if is_json_mode() {
306                JsonResponse::success(
307                    "credential revoke",
308                    serde_json::json!({ "credential_said": credential_said, "revoked": true }),
309                )
310                .print()?;
311            } else {
312                println!(
313                    "✓ Credential revoked (recorded in the issuer's tamper-evident history): {credential_said}"
314                );
315            }
316            Ok(())
317        }
318
319        CredentialSubcommand::List {
320            issuer,
321            include_revoked,
322        } => {
323            let ctx = build_auths_context(&repo_path, env_config, Some(passphrase_provider))?;
324            let issuer_alias = KeyAlias::new_unchecked(issuer.unwrap_or_default());
325            let credentials = list(&ctx, &issuer_alias).map_err(anyhow::Error::new)?;
326            let shown: Vec<_> = credentials
327                .into_iter()
328                .filter(|c| include_revoked || !c.revoked)
329                .collect();
330
331            if is_json_mode() {
332                let data: Vec<_> = shown
333                    .iter()
334                    .map(|c| {
335                        serde_json::json!({
336                            "credential_said": c.credential_said,
337                            "subject_did": c.subject_did,
338                            "capabilities": c.capabilities,
339                            "revoked": c.revoked,
340                        })
341                    })
342                    .collect();
343                JsonResponse::success(
344                    "credential list",
345                    serde_json::json!({ "credentials": data }),
346                )
347                .print()?;
348            } else if shown.is_empty() {
349                println!("No credentials issued by this identity.");
350            } else {
351                println!("Issued credentials:");
352                for c in &shown {
353                    let status = if c.revoked { " (revoked)" } else { "" };
354                    println!(
355                        "  {} → {} [{}]{}",
356                        c.credential_said,
357                        crate::ux::product_id(&c.subject_did),
358                        c.capabilities
359                            .iter()
360                            .map(|cap| cap.as_str())
361                            .collect::<Vec<_>>()
362                            .join(","),
363                        status
364                    );
365                }
366            }
367            Ok(())
368        }
369
370        CredentialSubcommand::Verify {
371            credential_said,
372            issuer,
373            require_witnesses,
374            usage_counter,
375        } => {
376            let ctx = build_auths_context(&repo_path, env_config, Some(passphrase_provider))?;
377            let issuer_alias = KeyAlias::new_unchecked(issuer);
378            let policy = if require_witnesses {
379                VerifierWitnessPolicy::RequireWitnesses
380            } else {
381                VerifierWitnessPolicy::Warn
382            };
383            let observation = match usage_counter {
384                Some(path) => Some(load_usage_observation(&path)?),
385                None => None,
386            };
387            // Clock at the presentation boundary.
388            #[allow(clippy::disallowed_methods)]
389            let now = chrono::Utc::now();
390            let rt = tokio::runtime::Runtime::new()?;
391            let verdict = rt
392                .block_on(verify_by_said_with_usage(
393                    &ctx,
394                    &issuer_alias,
395                    &credential_said,
396                    policy,
397                    now,
398                    &repo_path,
399                    observation,
400                ))
401                .map_err(anyhow::Error::new)?;
402            print_verdict(&credential_said, &verdict)
403        }
404    }
405}
406
407/// Load an observed usage count from a `{"said":…,"calls_used":N}` JSON file.
408///
409/// The presented count is *untrusted* caller input (e.g. an agent's reported call
410/// count); the verifier checks it against its own monotonic usage ledger. A missing
411/// or malformed file is a hard error — the cap was requested, so it must be enforced.
412fn load_usage_observation(path: &PathBuf) -> Result<UsageObservation> {
413    #[derive(serde::Deserialize)]
414    struct UsageCounterFile {
415        calls_used: u64,
416    }
417    let bytes = std::fs::read(path)
418        .map_err(|e| anyhow::anyhow!("usage counter file read failed ({}): {e}", path.display()))?;
419    let parsed: UsageCounterFile = serde_json::from_slice(&bytes).map_err(|e| {
420        anyhow::anyhow!("usage counter file parse failed ({}): {e}", path.display())
421    })?;
422    Ok(UsageObservation {
423        calls_used: parsed.calls_used,
424    })
425}
426
427/// Render a verification verdict to stdout (JSON or human-readable).
428fn print_verdict(credential_said: &str, verdict: &CredentialVerdict) -> Result<()> {
429    let valid = verdict.is_valid();
430    let (status, detail, as_of) = describe(verdict);
431
432    if is_json_mode() {
433        JsonResponse::success(
434            "credential verify",
435            serde_json::json!({
436                "credential_said": credential_said,
437                "valid": valid,
438                "status": status,
439                "detail": detail,
440                "as_of": as_of,
441            }),
442        )
443        .print()?;
444    } else if valid {
445        println!("✓ Credential is valid: {credential_said}");
446        if let Some(seq) = as_of {
447            println!("  as of the issuer's history revision {seq}");
448        }
449    } else {
450        println!("✗ Credential did not verify: {credential_said}");
451        println!("  status: {status}");
452        if let Some(d) = detail {
453            println!("  detail: {d}");
454        }
455    }
456    Ok(())
457}
458
459/// A `(status, detail, as_of_seq)` summary of a verdict for presentation.
460fn describe(verdict: &CredentialVerdict) -> (&'static str, Option<String>, Option<u128>) {
461    use auths_verifier::CredentialVerdict as Inner;
462    match verdict {
463        CredentialVerdict::StaleOrUnresolvable { as_of, reason } => (
464            "stale_or_unresolvable",
465            Some(reason.clone()),
466            Some(as_of.seq),
467        ),
468        CredentialVerdict::UsageCapExceeded {
469            as_of,
470            observed,
471            cap,
472        } => (
473            "cap_exceeded",
474            Some(format!(
475                "usage cap reached: {observed} of {cap} calls already spent"
476            )),
477            Some(as_of.seq),
478        ),
479        CredentialVerdict::UsageCounterRolledBack {
480            as_of,
481            observed,
482            high_water,
483        } => (
484            "usage_counter_rolled_back",
485            Some(format!(
486                "replayed usage counter: presented {observed}, but {high_water} already observed"
487            )),
488            Some(as_of.seq),
489        ),
490        CredentialVerdict::Resolved { verdict, as_of } => {
491            let seq = Some(as_of.seq);
492            match verdict {
493                Inner::Valid { .. } => ("valid", None, seq),
494                Inner::SaidMismatch => ("said_mismatch", None, seq),
495                Inner::SchemaInvalid => ("schema_invalid", None, seq),
496                Inner::IssuerSignatureInvalid => ("issuer_signature_invalid", None, seq),
497                Inner::RegistryNotEstablished => ("registry_not_established", None, seq),
498                Inner::CredentialRevoked { revoked_at } => (
499                    "revoked",
500                    Some(format!(
501                        "revoked at the issuer's history revision {revoked_at}"
502                    )),
503                    seq,
504                ),
505                Inner::Expired { expired_at, .. } => {
506                    ("expired", Some(format!("expired at {expired_at}")), seq)
507                }
508                Inner::WitnessQuorumNotMet {
509                    event,
510                    collected,
511                    required,
512                } => (
513                    "witness_quorum_not_met",
514                    Some(format!(
515                        "{event} anchor: {collected}/{required} witness receipts"
516                    )),
517                    seq,
518                ),
519                Inner::IssuerKelDuplicitous => ("issuer_kel_duplicitous", None, seq),
520            }
521        }
522    }
523}