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