diff --git a/crates/auths-cli/src/commands/artifact/mod.rs b/crates/auths-cli/src/commands/artifact/mod.rs index b20eecf5..c90cfb51 100644 --- a/crates/auths-cli/src/commands/artifact/mod.rs +++ b/crates/auths-cli/src/commands/artifact/mod.rs @@ -270,6 +270,11 @@ pub enum ArtifactSubcommand { /// verification, it can only narrow a valid verdict, never widen it. #[arg(long = "expect-signer", value_name = "DID")] expect_signer: Option, + /// Require the verified signer to be a rooted did:keri identity (a rotatable, revocable + /// key-state log), rejecting a bare did:key self-attestation. Fails closed; applied after + /// verification, it can only narrow a valid verdict, never widen it. + #[arg(long = "require-rooted-signer")] + require_rooted_signer: bool, }, } @@ -668,6 +673,7 @@ pub fn handle_artifact( log_evidence, log_key, expect_signer, + require_rooted_signer, } => { if offline { return verify::handle_offline_verify( @@ -699,6 +705,7 @@ pub fn handle_artifact( log_evidence, log_key, expect_signer, + require_rooted_signer, }, )) } diff --git a/crates/auths-cli/src/commands/artifact/verify.rs b/crates/auths-cli/src/commands/artifact/verify.rs index bdeb0482..2155670d 100644 --- a/crates/auths-cli/src/commands/artifact/verify.rs +++ b/crates/auths-cli/src/commands/artifact/verify.rs @@ -96,6 +96,10 @@ pub struct VerifyArtifactArgs { /// exactly this identity. Applied AFTER verification as an allowlist — it can only /// narrow a `valid` verdict to invalid on a signer mismatch, never widen it. pub expect_signer: Option, + /// Require the verified signer to be a rooted `did:keri` identity (a rotatable, revocable + /// key-state log), rejecting a bare `did:key` self-attestation. Applied AFTER verification; + /// it can only narrow a `valid` verdict to invalid, never widen it. + pub require_rooted_signer: bool, } /// Decide whether the verified signer satisfies an `--expect-signer` allowlist. @@ -115,6 +119,29 @@ fn expected_signer_mismatch(issuer: &str, expect: Option<&str>) -> Option Option { + if require_rooted && !issuer.starts_with("did:keri:") { + Some(format!( + "Signer not root-authorized: {issuer} is a did:key self-attestation, not a \ + rotatable did:keri identity backed by a key-state log" + )) + } else { + None + } +} + /// Execute the `artifact verify` command. /// /// Exit codes: 0=valid, 1=invalid, 2=error. @@ -132,6 +159,7 @@ pub async fn handle_verify(file: &Path, args: VerifyArtifactArgs) -> Result<()> log_evidence, log_key, expect_signer, + require_rooted_signer, } = args; let witness_keys = &witness_keys; let file_str = file.to_string_lossy().to_string(); @@ -322,6 +350,28 @@ pub async fn handle_verify(file: &Path, args: VerifyArtifactArgs) -> Result<()> ); } + // --require-rooted-signer: a bare did:key self-attestation has no key-state log, so it cannot + // be rotated or revoked. When a rooted signer is demanded, reject it; narrows the verdict only. + if let Some(msg) = unrooted_signer_rejected(attestation.issuer.as_str(), require_rooted_signer) + { + return output_result( + 1, + VerifyArtifactResult { + file: file_str.clone(), + valid: false, + digest_match: Some(true), + chain_valid, + chain_report: chain_report.clone(), + witness_quorum: None, + issuer: Some(attestation.issuer.to_string()), + commit_sha: attestation.commit_sha.clone(), + commit_verified: None, + oidc_join: None, + error: Some(msg), + }, + ); + } + // 6b. Offline transparency anchoring. With inclusion evidence supplied, // the verdict's `anchored` field is decided by the proof: Anchored // only when the evidence binds to THIS artifact's digest, its Merkle @@ -1057,8 +1107,26 @@ pub(crate) fn raw_commit_bytes(repo: &git2::Repository, oid: git2::Oid) -> Resul mod tests { use super::expected_signer_mismatch; use super::raw_commit_bytes; + use super::unrooted_signer_rejected; use std::process::Command; + #[test] + fn unrooted_signer_rejected_blocks_did_key_self_attestation() { + // A bare did:key signer is a self-attestation with no key-state log: when a + // rooted signer is required, it must fail closed. + assert!( + unrooted_signer_rejected("did:key:z6MkExample", true).is_some(), + "a did:key self-attestation must be rejected when a rooted signer is required" + ); + // A did:keri signer is backed by a rotatable, revocable KEL — accepted. + assert!( + unrooted_signer_rejected("did:keri:EExample", true).is_none(), + "a did:keri signer is root-authorized and must pass" + ); + // When the policy is off, the verdict is not narrowed. + assert!(unrooted_signer_rejected("did:key:z6MkExample", false).is_none()); + } + /// Regression: the bytes the verifier checks the SSH signature over must /// be byte-identical to `git cat-file commit`. A prior implementation /// reconstructed them from raw_header + "\n\n" + message, drifting by one diff --git a/crates/auths-cli/src/commands/unified_verify.rs b/crates/auths-cli/src/commands/unified_verify.rs index bed599c7..4dbb9777 100644 --- a/crates/auths-cli/src/commands/unified_verify.rs +++ b/crates/auths-cli/src/commands/unified_verify.rs @@ -204,6 +204,7 @@ pub async fn handle_verify_unified( log_evidence: None, log_key: None, expect_signer: None, + require_rooted_signer: false, }, ) .await