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, 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#[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#[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
111 Present {
113 #[arg(long, help = "The subject (holder) keychain alias to sign with.")]
115 subject: String,
116
117 #[arg(long = "said", help = "The credential SAID to present.")]
119 said: String,
120
121 #[arg(long, help = "The relying-party audience to bind to.")]
123 audience: String,
124
125 #[arg(long, help = "The base64url challenge nonce from /v1/auth/challenge.")]
127 nonce: String,
128 },
129}
130
131#[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
152pub 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 #[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 #[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
345fn 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
377fn 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}