Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions crates/auths-cli/src/commands/artifact/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 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,
},
}

Expand Down Expand Up @@ -668,6 +673,7 @@ pub fn handle_artifact(
log_evidence,
log_key,
expect_signer,
require_rooted_signer,
} => {
if offline {
return verify::handle_offline_verify(
Expand Down Expand Up @@ -699,6 +705,7 @@ pub fn handle_artifact(
log_evidence,
log_key,
expect_signer,
require_rooted_signer,
},
))
}
Expand Down
68 changes: 68 additions & 0 deletions crates/auths-cli/src/commands/artifact/verify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 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.
Expand All @@ -115,6 +119,29 @@ fn expected_signer_mismatch(issuer: &str, expect: Option<&str>) -> Option<String
}
}

/// Decide whether the verified signer satisfies a "require a rooted signer" policy.
///
/// Pure: a bare `did:key` signer is a self-attestation — the key is its own identity, with no
/// key-state log behind it, so it cannot be rotated or revoked (a leaked key stays valid
/// forever). When the caller requires a rooted signer, only a `did:keri` issuer — whose authority
/// is a replayable, rotatable, revocable key-state log — is accepted; a `did:key`
/// self-attestation fails closed. Applied only after an attestation has otherwise verified, and
/// it can only narrow a verdict to invalid, never widen it.
///
/// Args:
/// * `issuer`: the verified attestation issuer (the signer).
/// * `require_rooted`: whether the caller demands a rooted (`did:keri`) signer.
fn unrooted_signer_rejected(issuer: &str, require_rooted: bool) -> Option<String> {
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.
Expand All @@ -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();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions crates/auths-cli/src/commands/unified_verify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ pub async fn handle_verify_unified(
log_evidence: None,
log_key: None,
expect_signer: None,
require_rooted_signer: false,
},
)
.await
Expand Down
Loading