1use anyhow::{Context, Result, anyhow};
13use chrono::{DateTime, Utc};
14use clap::{Parser, Subcommand, ValueEnum};
15use std::fs;
16use std::path::{Path, PathBuf};
17
18use auths_crypto::CurveType;
19use auths_sdk::context::AuthsContext;
20use auths_sdk::keychain::{KeyAlias, extract_public_key_bytes};
21use auths_sdk::storage_layout::layout;
22use auths_sdk::workflows::compliance::{
23 ArtifactDigest, ComplianceFramework, ReleaseRecord, TransparencyInclusion,
24 VerifiedEvidencePack, VsaParams, attest_release, build_evidence_pack, build_framework_report,
25 build_offline_evidence_pack, discover_releases, load_witness_policy, sign_evidence_pack,
26 sign_framework_report, verify_signed_evidence_pack_offline,
27};
28use auths_sdk::workflows::org::resolve_org_signing_alias;
29use auths_sdk::workflows::roots::parse_roots_typed;
30use auths_verifier::{Ed25519PublicKey, IdentityDID, Prefix};
31
32use crate::commands::executable::ExecutableCommand;
33use crate::config::CliConfig;
34use crate::factories::storage::build_auths_context;
35use crate::ux::format::{JsonResponse, Output, is_json_mode};
36
37fn resolve_org_signing(
44 sdk_ctx: &AuthsContext,
45 org: &str,
46 key: Option<String>,
47) -> Result<(KeyAlias, CurveType)> {
48 let org_prefix = org.strip_prefix("did:keri:").unwrap_or(org);
49 let org_alias = resolve_org_signing_alias(sdk_ctx.key_storage.as_ref(), org_prefix, key)
50 .map_err(anyhow::Error::from)?;
51 let (_pk, curve) = extract_public_key_bytes(
52 sdk_ctx.key_storage.as_ref(),
53 &org_alias,
54 sdk_ctx.passphrase_provider.as_ref(),
55 )
56 .with_context(|| format!("Failed to resolve org signing key '{org_alias}'"))?;
57 Ok((org_alias, curve))
58}
59
60fn file_artifact_digest(path: &Path) -> Result<ArtifactDigest> {
62 use sha2::{Digest, Sha256};
63 let bytes = fs::read(path).with_context(|| format!("Failed to read artifact file {path:?}"))?;
64 let digest = format!("sha256:{}", hex::encode(Sha256::digest(&bytes)));
65 ArtifactDigest::parse(&digest).map_err(|e| anyhow!("computed digest rejected: {e}"))
66}
67
68#[derive(Debug, Clone, serde::Deserialize)]
71struct ReleaseInput {
72 artifact_digest: String,
73 signer: String,
74 #[serde(default)]
75 signed_at: Option<u128>,
76 #[serde(default)]
77 transparency: Option<TransparencyInclusion>,
78}
79
80impl ReleaseInput {
81 fn into_record(self) -> ReleaseRecord {
82 let signer_prefix = Prefix::new_unchecked(
83 self.signer
84 .strip_prefix("did:keri:")
85 .unwrap_or(&self.signer)
86 .to_string(),
87 );
88 ReleaseRecord {
89 artifact_digest: self.artifact_digest,
90 signer_prefix,
91 signed_at: self.signed_at,
92 transparency: self.transparency,
93 }
94 }
95}
96
97#[derive(Debug, Clone, Copy, ValueEnum)]
99pub enum CliFramework {
100 Slsa,
102 Sbom,
104 Cra,
106 Soc2,
108 Iso27001,
110}
111
112impl From<CliFramework> for ComplianceFramework {
113 fn from(f: CliFramework) -> Self {
114 match f {
115 CliFramework::Slsa => ComplianceFramework::Slsa,
116 CliFramework::Sbom => ComplianceFramework::Sbom,
117 CliFramework::Cra => ComplianceFramework::Cra,
118 CliFramework::Soc2 => ComplianceFramework::Soc2,
119 CliFramework::Iso27001 => ComplianceFramework::Iso27001,
120 }
121 }
122}
123
124#[derive(Parser, Debug, Clone)]
126#[command(
127 about = "Compliance as a query — offline-verifiable evidence packs",
128 after_help = "Examples:
129 auths compliance attest --org did:keri:EOrg --artifact dist/cli-v2.4.0.tar.gz \\
130 --signer did:keri:EMember
131 # At signing time: anchor the release (artifact digest +
132 # signer) in the org KEL — the position becomes log fact
133
134 auths compliance report --org did:keri:EOrg --period 2026-Q3 \\
135 --framework slsa --discover --offline --out acme-2026Q3.evidence
136 # Build an offline-verifiable evidence pack from the
137 # releases anchored in the org KEL (signed_at derived)
138
139 auths compliance verify --pack acme-2026Q3.evidence --roots auths-roots \\
140 --log-key auths-log.pub
141 # Auditor-side: verify a signed pack offline (exit 0
142 # authentic / exit 1 rejected) — no account, no network.
143 # --log-key pins the log operator: every row's checkpoint
144 # signature must verify, not just its Merkle membership
145
146Releases file (JSON array, caller-asserted alternative to --discover):
147 [{\"artifact_digest\":\"sha256:…\",\"signer\":\"did:keri:EMember\",\"signed_at\":41}]"
148)]
149pub struct ComplianceCommand {
150 #[clap(subcommand)]
151 pub subcommand: ComplianceSubcommand,
152}
153
154#[derive(Subcommand, Debug, Clone)]
156pub enum ComplianceSubcommand {
157 #[command(group(clap::ArgGroup::new("artifact_source").required(true)))]
160 Attest {
161 #[arg(long)]
163 org: String,
164
165 #[arg(long, group = "artifact_source")]
167 artifact: Option<PathBuf>,
168
169 #[arg(long, group = "artifact_source")]
171 digest: Option<String>,
172
173 #[arg(long)]
175 signer: String,
176
177 #[arg(long)]
179 key: Option<String>,
180 },
181
182 #[command(group(clap::ArgGroup::new("release_source").required(true)))]
184 Report {
185 #[arg(long)]
187 org: String,
188
189 #[arg(long)]
191 period: String,
192
193 #[arg(long, value_enum, default_value = "slsa")]
197 framework: CliFramework,
198
199 #[arg(long)]
201 predicate: bool,
202
203 #[arg(long, default_value = "https://auths.dev/compliance")]
205 verifier_id: String,
206
207 #[arg(long, group = "release_source")]
210 releases: Option<PathBuf>,
211
212 #[arg(long, group = "release_source")]
215 discover: bool,
216
217 #[arg(long)]
219 offline: bool,
220
221 #[arg(long)]
223 sign: bool,
224
225 #[arg(long)]
227 key: Option<String>,
228
229 #[arg(long)]
231 witness_policy: Option<PathBuf>,
232
233 #[arg(long)]
235 out: Option<PathBuf>,
236 },
237
238 Verify {
240 #[arg(long)]
242 pack: PathBuf,
243
244 #[arg(long)]
246 roots: PathBuf,
247
248 #[arg(long)]
253 log_key: Option<PathBuf>,
254 },
255}
256
257fn parse_pinned_log_key(path: &Path) -> Result<Ed25519PublicKey> {
260 let raw = fs::read_to_string(path)
261 .with_context(|| format!("Failed to read pinned log-key file {path:?}"))?;
262 let line = raw
263 .lines()
264 .map(str::trim)
265 .find(|l| !l.is_empty() && !l.starts_with('#'))
266 .ok_or_else(|| anyhow!("pinned log-key file {path:?} contains no key"))?;
267 let bytes = hex::decode(line)
268 .map_err(|e| anyhow!("pinned log-key file {path:?} is not valid hex: {e}"))?;
269 Ed25519PublicKey::try_from_slice(&bytes)
270 .map_err(|e| anyhow!("pinned log-key file {path:?} rejected: {e}"))
271}
272
273pub fn handle_compliance(
285 cmd: ComplianceCommand,
286 ctx: &CliConfig,
287 now: DateTime<Utc>,
288) -> Result<()> {
289 match cmd.subcommand {
290 ComplianceSubcommand::Attest {
291 org,
292 artifact,
293 digest,
294 signer,
295 key,
296 } => {
297 let repo_path = layout::resolve_repo_path(ctx.repo_path.clone())?;
298 let org_prefix =
299 Prefix::new_unchecked(org.strip_prefix("did:keri:").unwrap_or(&org).to_string());
300 let signer_prefix = Prefix::new_unchecked(
301 signer
302 .strip_prefix("did:keri:")
303 .unwrap_or(&signer)
304 .to_string(),
305 );
306
307 let artifact_digest = match (&artifact, &digest) {
308 (Some(path), None) => file_artifact_digest(path)?,
309 (None, Some(d)) => {
310 ArtifactDigest::parse(d).map_err(|e| anyhow!("invalid --digest value: {e}"))?
311 }
312 _ => unreachable!("clap requires exactly one of --artifact/--digest"),
313 };
314
315 let sdk_ctx = build_auths_context(
316 &repo_path,
317 &ctx.env_config,
318 Some(ctx.passphrase_provider.clone()),
319 )?;
320 let (org_alias, _curve) = resolve_org_signing(&sdk_ctx, &org, key)?;
321 let anchored = attest_release(
322 &sdk_ctx,
323 &org_prefix,
324 &org_alias,
325 artifact_digest,
326 signer_prefix,
327 )
328 .context("Failed to anchor the release attestation in the org KEL")?;
329
330 if is_json_mode() {
331 JsonResponse {
332 success: true,
333 command: "compliance attest".to_string(),
334 data: Some(serde_json::json!({
335 "org": format!("did:keri:{}", org_prefix.as_str()),
336 "artifact_digest": anchored.artifact_digest.as_str(),
337 "signer": format!("did:keri:{}", anchored.signer.as_str()),
338 "signed_at": anchored.signed_at.to_string(),
339 "attestation_said": anchored.attestation_said.as_str(),
340 })),
341 error: None,
342 }
343 .print()?;
344 } else {
345 let out = Output::stdout();
346 println!(
347 "{}",
348 out.success("Release attestation anchored in the org KEL")
349 );
350 println!(" Artifact: {}", anchored.artifact_digest);
351 println!(" Signer: did:keri:{}", anchored.signer.as_str());
352 println!(" Signed at: org KEL seq {}", anchored.signed_at);
353 println!(" SAID: {}", anchored.attestation_said.as_str());
354 }
355 Ok(())
356 }
357
358 ComplianceSubcommand::Report {
359 org,
360 period,
361 framework,
362 predicate,
363 verifier_id,
364 releases,
365 discover,
366 offline,
367 sign,
368 key,
369 witness_policy,
370 out,
371 } => {
372 let repo_path = layout::resolve_repo_path(ctx.repo_path.clone())?;
373 let passphrase_provider = ctx.passphrase_provider.clone();
374
375 let org_prefix =
376 Prefix::new_unchecked(org.strip_prefix("did:keri:").unwrap_or(&org).to_string());
377 let org_did = IdentityDID::from_prefix(org_prefix.as_str())
378 .map_err(|e| anyhow!("invalid org identifier '{org}': {e}"))?;
379
380 let policy_path = witness_policy.or_else(|| {
381 std::env::var("AUTHS_WITNESS_POLICY_PATH")
382 .ok()
383 .map(PathBuf::from)
384 });
385 let policy_result = load_witness_policy(policy_path.as_deref());
386
387 let sdk_ctx = build_auths_context(
388 &repo_path,
389 &ctx.env_config,
390 Some(passphrase_provider.clone()),
391 )?;
392
393 let records: Vec<ReleaseRecord> = if discover {
394 discover_releases(sdk_ctx.registry.as_ref(), &org_prefix)
395 .context("Failed to discover anchored releases from the org KEL")?
396 } else {
397 let Some(releases) = releases else {
399 return Err(anyhow!("one of --releases or --discover is required"));
400 };
401 let raw = fs::read_to_string(&releases)
402 .with_context(|| format!("Failed to read releases file {releases:?}"))?;
403 let inputs: Vec<ReleaseInput> = serde_json::from_str(&raw)
404 .with_context(|| format!("Invalid JSON in releases file {releases:?}"))?;
405 inputs.into_iter().map(ReleaseInput::into_record).collect()
406 };
407 let framework = ComplianceFramework::from(framework);
408
409 let pack = if offline {
410 build_offline_evidence_pack(
411 &sdk_ctx,
412 org_did.clone(),
413 &org_prefix,
414 period,
415 framework,
416 &records,
417 &policy_result,
418 now,
419 )
420 } else {
421 build_evidence_pack(
422 &sdk_ctx,
423 org_did.clone(),
424 &org_prefix,
425 period,
426 framework,
427 &records,
428 &policy_result,
429 now,
430 )
431 }
432 .context("Failed to build compliance evidence pack")?;
433
434 let output = if predicate {
435 let vsa = VsaParams {
436 verifier_id: verifier_id.clone(),
437 time_verified: now,
438 allow_list: Default::default(),
439 };
440 let report = build_framework_report(&pack, &vsa)
441 .context("Failed to render the framework predicate")?;
442 if sign {
443 let (org_alias, curve) = resolve_org_signing(&sdk_ctx, &org, key)?;
444 sign_framework_report(&sdk_ctx, org_did.as_str(), &org_alias, curve, &report)
445 .context("Failed to org-sign the framework predicate")?
446 .to_canonical_json()
447 .context("Failed to serialize DSSE envelope")?
448 } else {
449 report
450 .to_intoto_statement()
451 .context("Failed to serialize the framework predicate")?
452 }
453 } else if sign {
454 let (org_alias, curve) = resolve_org_signing(&sdk_ctx, &org, key)?;
455 sign_evidence_pack(&sdk_ctx, org_did.as_str(), &org_alias, curve, &pack)
456 .context("Failed to org-sign the evidence pack")?
457 .to_canonical_json()
458 .context("Failed to serialize DSSE envelope")?
459 } else {
460 pack.canonicalize()
461 .context("Failed to serialize evidence pack")?
462 };
463
464 match &out {
465 Some(path) => {
466 fs::write(path, &output)
467 .with_context(|| format!("Failed to write pack to {path:?}"))?;
468 eprintln!("✅ Compliance evidence pack written to {path:?}");
469 eprintln!(" Rows: {}", pack.rows.len());
470 eprintln!(" Offline-verifiable: {}", pack.org_bundle.is_some());
471 eprintln!(" Org-signed (DSSE): {sign}");
472 eprintln!(
473 " Witness verdict: {}",
474 pack.equivocation_visibility.label
475 );
476 }
477 None => println!("{output}"),
478 }
479 Ok(())
480 }
481
482 ComplianceSubcommand::Verify {
483 pack,
484 roots,
485 log_key,
486 } => {
487 let envelope_json = fs::read_to_string(&pack)
488 .with_context(|| format!("Failed to read evidence pack {pack:?}"))?;
489 let roots_raw = fs::read_to_string(&roots)
490 .with_context(|| format!("Failed to read pinned-roots file {roots:?}"))?;
491 let pinned = parse_roots_typed(&roots_raw)
492 .map_err(|e| anyhow!("pinned roots file rejected: {e}"))?;
493 let pinned_log_key = log_key.as_deref().map(parse_pinned_log_key).transpose()?;
494
495 let verified = verify_signed_evidence_pack_offline(
498 &envelope_json,
499 &pinned,
500 pinned_log_key.as_ref(),
501 )
502 .map_err(|e| anyhow!("evidence REJECTED: {e}"))?;
503 let authentic = verified.authentic();
504
505 if is_json_mode() {
506 JsonResponse {
507 success: authentic,
508 command: "compliance verify".to_string(),
509 data: Some(verify_verdict_json(
510 &pack,
511 &verified,
512 authentic,
513 pinned_log_key.is_some(),
514 )),
515 error: (!authentic)
516 .then(|| "a row is inconsistent with the embedded log".to_string()),
517 }
518 .print()?;
519 } else {
520 print_verify_report(&pack, &verified, authentic, pinned_log_key.is_some());
521 }
522
523 if !authentic {
526 return Err(anyhow!(
527 "evidence REJECTED — a row is inconsistent with the embedded log"
528 ));
529 }
530 Ok(())
531 }
532 }
533}
534
535fn authority_label(verdict: &auths_sdk::workflows::compliance::RowVerdict) -> String {
538 serde_json::to_value(&verdict.authority_at_release)
539 .ok()
540 .and_then(|v| v["authority_at_signing"].as_str().map(|s| s.to_string()))
541 .unwrap_or_else(|| "?".to_string())
542}
543
544fn verify_verdict_json(
546 pack_path: &Path,
547 verified: &VerifiedEvidencePack,
548 authentic: bool,
549 log_key_pinned: bool,
550) -> serde_json::Value {
551 serde_json::json!({
552 "pack": pack_path,
553 "org": verified.pack.org.as_str(),
554 "period": verified.pack.period,
555 "framework": verified.pack.framework,
556 "dsse_signature": "verified",
557 "org_key_source": "authenticated embedded KEL",
558 "org_kel_seq": verified.org_kel_seq.to_string(),
559 "root_pinned": true,
560 "log_key_pinned": log_key_pinned,
561 "rows": verified.verdicts,
562 "authentic": authentic,
563 })
564}
565
566fn print_verify_report(
568 pack_path: &Path,
569 verified: &VerifiedEvidencePack,
570 authentic: bool,
571 log_key_pinned: bool,
572) {
573 let out = Output::stdout();
574 println!(
575 "Offline evidence-pack verification of {}",
576 pack_path.display()
577 );
578 println!(" Org: {}", verified.pack.org.as_str());
579 println!(
580 " Period: {} Framework: {:?}",
581 verified.pack.period, verified.pack.framework
582 );
583 println!(
584 " {}",
585 out.success(
586 "DSSE signature verified — org key resolved from the authenticated KEL, not a keychain"
587 )
588 );
589 println!(
590 " {}",
591 out.success(&format!(
592 "Root pinned · no duplicity · org KEL signature-authenticated (seq {})",
593 verified.org_kel_seq
594 ))
595 );
596 if log_key_pinned {
597 println!(
598 " {}",
599 out.success(
600 "Log-operator key pinned — checkpoint signatures verified, not just Merkle membership"
601 )
602 );
603 }
604 println!(" Rows:");
605 for v in &verified.verdicts {
606 let label = authority_label(v);
607 let row_ok = v.authority_consistent && label.starts_with("authorized");
608 let mark = if v.authority_consistent { "✓" } else { "✗" };
609 let transparency = match (v.transparency_verified, v.checkpoint_attested) {
610 (Some(true), Some(true)) => "logged+operator-attested",
611 (Some(true), Some(false)) => "CHECKPOINT-UNATTESTED",
612 (Some(true), None) => "logged",
613 (Some(false), _) => "TRANSPARENCY-FAIL",
614 (None, _) => "unlogged",
615 };
616 let line = format!(
617 "{mark} {} {} {label}",
618 truncated(&v.artifact_digest, 19),
619 truncated(v.signer.as_str(), 21),
620 );
621 let line = if row_ok {
622 out.success(&line)
623 } else {
624 out.error(&line)
625 };
626 println!(
627 " {line} {}",
628 out.dim(&format!(
629 "consistent={} transparency={transparency}",
630 v.authority_consistent
631 ))
632 );
633 }
634 if authentic {
635 println!(
636 " {}",
637 out.success(
638 "Verdict: AUTHENTIC — evidence verified offline, trusting only the pinned roots"
639 )
640 );
641 } else {
642 println!(
643 " {}",
644 out.error("Verdict: REJECTED — evidence inconsistent with its own log")
645 );
646 }
647}
648
649fn truncated(s: &str, n: usize) -> String {
651 let cut: String = s.chars().take(n).collect();
652 if cut.len() < s.len() {
653 format!("{cut}…")
654 } else {
655 cut
656 }
657}
658
659impl ExecutableCommand for ComplianceCommand {
660 #[allow(clippy::disallowed_methods)] fn execute(&self, ctx: &CliConfig) -> Result<()> {
662 handle_compliance(self.clone(), ctx, Utc::now())
663 }
664}