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}
148
149#[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
170pub 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 #[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 #[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
375fn 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
395fn 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
427fn 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}