1use 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#[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#[derive(Subcommand, Debug, Clone)]
47pub enum CredentialSubcommand {
48 Issue {
50 #[arg(long, help = "The issuer's signing key name (your identity's key).")]
52 issuer: String,
53
54 #[arg(long = "to", help = "The issuee/subject did:keri to credential.")]
56 to: String,
57
58 #[arg(long = "cap", help = "Capability to grant (repeatable).")]
60 cap: Vec<auths_keri::Capability>,
61
62 #[arg(long, help = "Informational role claim (e.g. deployer).")]
64 role: Option<String>,
65
66 #[arg(long = "expires-in", help = "Expire the credential after N seconds.")]
68 expires_in: Option<i64>,
69 },
70
71 Revoke {
73 #[arg(help = "The credential SAID to revoke.")]
75 credential_said: String,
76
77 #[arg(long, help = "The issuer's signing key name.")]
79 issuer: String,
80 },
81
82 List {
84 #[arg(long, help = "The issuer's signing key name.")]
86 issuer: Option<String>,
87
88 #[arg(long, help = "Include revoked credentials.")]
90 include_revoked: bool,
91 },
92
93 Verify {
95 #[arg(help = "The credential SAID to verify.")]
97 credential_said: String,
98
99 #[arg(long, help = "The issuer's signing key name.")]
101 issuer: String,
102
103 #[arg(
105 long = "require-witnesses",
106 help = "Fail closed unless every lifecycle anchor reaches witness quorum."
107 )]
108 require_witnesses: bool,
109
110 #[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 {
127 #[arg(long, help = "The subject (holder) keychain alias to sign with.")]
129 subject: String,
130
131 #[arg(long = "said", help = "The credential SAID to present.")]
133 said: String,
134
135 #[arg(long, help = "The relying-party audience to bind to.")]
137 audience: String,
138
139 #[arg(
141 long,
142 allow_hyphen_values = true,
143 help = "The base64url challenge nonce from /v1/auth/challenge."
144 )]
145 nonce: String,
146
147 #[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#[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
178pub 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 #[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 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 #[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
407fn 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
427fn 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
459fn 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}