Skip to content
Merged
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
3 changes: 2 additions & 1 deletion crates/auths-cli/src/commands/artifact/verify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use auths_verifier::core::Attestation;
use auths_verifier::evidence_pack::{
TransparencyInclusion, parse_log_key_hex, verify_artifact_log_inclusion,
};
use auths_verifier::freshness::FreshnessPolicy;
use auths_verifier::oidc_policy::{OidcPolicyJoin, OidcSubjectPolicy};
use auths_verifier::witness::{WitnessQuorum, WitnessVerifyConfig};
use auths_verifier::{
Expand Down Expand Up @@ -912,7 +913,7 @@ async fn verify_commit_in_process(sha: &str) -> bool {
)
.await
{
Ok(verdict) if verdict.is_valid() => true,
Ok(verdict) if verdict.is_trusted(&FreshnessPolicy::default()) => true,
Ok(verdict) => {
if !is_json_mode() {
eprintln!("Commit {short} is not authorized by a pinned trusted root: {verdict:?}");
Expand Down
30 changes: 16 additions & 14 deletions crates/auths-cli/src/commands/verify_commit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -637,18 +637,17 @@ fn verdict_to_result(commit: String, verdict: CommitVerdict) -> VerifyCommitResu
result.signer = Some(signer_did);
result.error = if trusted {
None
} else if duplicitous_root {
Some(format!(
"Root {root_did} shows KEL duplicity (a fork) — not trusted. \
Resolve with `auths device remove`."
))
} else {
Some(format!(
"commit verified but its freshness is {freshness:?}; the supplied slice is \
older than the verifier's trust window"
))
};
if duplicitous_root {
result.warnings.push(format!(
"Root {root_did} shows KEL duplicity (a fork) — trusting the first event \
seen. Resolve with `auths device remove`."
));
}
}
CommitVerdict::Unsigned => {
result.error = Some("No signature found".to_string());
Expand Down Expand Up @@ -1093,8 +1092,9 @@ mod tests {
}

#[test]
fn verify_output_flags_fork() {
// A Valid verdict on a duplicitous root must surface a non-fatal fork warning.
fn verify_output_fails_closed_on_fork() {
// A Valid verdict on a duplicitous root must FAIL CLOSED (not trusted) and explain why —
// the relying party cannot tell which branch is real.
let result = verdict_to_result(
"sha".into(),
CommitVerdict::Valid {
Expand All @@ -1105,14 +1105,16 @@ mod tests {
freshness: auths_verifier::freshness::Freshness::Unknown,
},
);
assert!(result.valid);
assert!(!result.valid, "a duplicitous root must fail closed");
assert!(
result
.warnings
.iter()
.any(|w| w.contains("fork") || w.contains("duplicity")),
"expected a fork/duplicity warning, got {:?}",
result.warnings
.error
.as_deref()
.unwrap_or_default()
.to_lowercase()
.contains("duplicity"),
"expected a duplicity error, got {:?}",
result.error
);
}

Expand Down
34 changes: 32 additions & 2 deletions crates/auths-sdk/src/workflows/commit_trust.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,9 +162,11 @@ pub struct CommitDecision {
#[cfg(feature = "backend-git")]
impl CommitDecision {
/// True iff the commit is cryptographically valid **and** org policy authorizes it
/// (or the root anchored no policy). Fail-closed: any policy deny/indeterminate, or
/// a non-`Valid` verdict, is unauthorized.
/// (or the root anchored no policy). Fail-closed: any policy deny/indeterminate, a
/// non-`Valid` verdict, **or a duplicitous (forked) root KEL** is unauthorized.
pub fn is_authorized(&self) -> bool {
// Fail-closed on a duplicitous root KEL is enforced by `verdict.is_trusted` below
// (the shared trust primitive the CLI also consumes), not re-derived here.
if !self.verdict.is_trusted(&FreshnessPolicy::default()) {
return false;
}
Expand Down Expand Up @@ -363,4 +365,32 @@ mod tests {
Some((ROOT.to_string(), DEVICE.to_string()))
);
}

#[cfg(feature = "backend-git")]
#[test]
fn duplicitous_root_is_not_authorized_even_when_valid_and_fresh() {
use auths_verifier::freshness::Freshness;
let decision = |duplicitous_root: bool| CommitDecision {
verdict: CommitVerdict::Valid {
signer_did: DEVICE.to_string(),
root_did: ROOT.to_string(),
duplicitous_root,
as_of: 1,
freshness: Freshness::Fresh,
},
policy: PolicyOutcome::NoPolicy,
};
// Fail-closed: a forked (duplicitous) root KEL is not trustworthy even with a valid,
// fresh signature and no org policy to deny it — the relying party cannot tell which
// branch is real.
assert!(
!decision(true).is_authorized(),
"a duplicitous root must fail closed"
);
// Control: the same commit without divergence is authorized.
assert!(
decision(false).is_authorized(),
"a clean, valid, fresh, policy-clear commit is authorized"
);
}
}
16 changes: 13 additions & 3 deletions crates/auths-verifier/src/commit_kel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,13 +207,23 @@ impl CommitVerdict {
}
}

/// Whether this verdict is trusted under a freshness policy: it is `Valid` AND its
/// freshness clears the policy. A bare `Valid` is never trusted without freshness — the
/// relying party's policy (not the signer) decides the tolerance (ADR 009).
/// Whether this verdict is trusted under a freshness policy: it is `Valid`, its root KEL is
/// **not duplicitous** (a fork fails closed — the relying party cannot tell which branch is
/// real), AND its freshness clears the policy. A bare `Valid` is never trusted without
/// freshness — the relying party's policy (not the signer) decides the tolerance (ADR 009).
///
/// Args:
/// * `policy`: the relying party's freshness policy.
pub fn is_trusted(&self, policy: &FreshnessPolicy) -> bool {
if matches!(
self,
CommitVerdict::Valid {
duplicitous_root: true,
..
}
) {
return false;
}
self.is_valid() && policy.trusts(self.freshness())
}

Expand Down
70 changes: 70 additions & 0 deletions crates/auths-verifier/src/duplicity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,43 @@ pub fn detect_duplicity(events: &[KelEventRef<'_>]) -> DuplicityReport {
DuplicityReport::Clean
}

/// Seal-bounding: `true` when `candidate` builds *past* a disputed position — its
/// sequence is beyond the divergence and its predecessor is one of the conflicting
/// heads. A seal-bounded producer or verifier refuses such an extension, so an
/// equivocator gains nothing durable from a fork it created, even before global
/// detection converges. A `Clean` report seals nothing. Ported from the `timeline_proof`
/// harness (`vdti::seal_rejects_extension`).
///
/// Args:
/// * `report`: The divergence that froze the position.
/// * `candidate_seq`: The sequence number of the event being appended.
/// * `candidate_prev_said`: The SAID the candidate links back to (its `p` field).
///
/// Usage:
/// ```
/// use auths_verifier::duplicity::{KelEventRef, detect_duplicity, seal_rejects_extension};
///
/// let events = vec![
/// KelEventRef { prefix: "did:keri:EShared", seq: 2, said: "ErotA" },
/// KelEventRef { prefix: "did:keri:EShared", seq: 2, said: "ErotB" },
/// ];
/// let report = detect_duplicity(&events);
/// assert!(seal_rejects_extension(&report, 3, "ErotA")); // builds past the fork → refused
/// assert!(!seal_rejects_extension(&report, 3, "Eunrelated")); // unrelated predecessor → allowed
/// ```
pub fn seal_rejects_extension(
report: &DuplicityReport,
candidate_seq: u64,
candidate_prev_said: &str,
) -> bool {
match report {
DuplicityReport::Diverging {
seq, event_saids, ..
} => candidate_seq > *seq && event_saids.iter().any(|s| s == candidate_prev_said),
DuplicityReport::Clean => false,
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -232,4 +269,37 @@ mod tests {
assert!(report.is_diverging());
assert!(!DuplicityReport::Clean.is_diverging());
}

fn diverging_at_seq2() -> DuplicityReport {
DuplicityReport::Diverging {
#[allow(clippy::disallowed_methods)]
shared_kel_prefix: IdentityDID::new_unchecked("did:keri:EShared".to_string()),
seq: 2,
event_saids: vec!["ErotA".into(), "ErotB".into()],
}
}

#[test]
fn seal_rejects_extension_built_on_a_disputed_head() {
// An equivocator forks at seq 2, then tries to advance one branch at seq 3.
// Seal-bounding refuses it — building past a disputed position gains nothing durable.
let report = diverging_at_seq2();
assert!(seal_rejects_extension(&report, 3, "ErotA"));
assert!(seal_rejects_extension(&report, 3, "ErotB"));
}

#[test]
fn seal_allows_extension_not_descending_from_the_dispute() {
let report = diverging_at_seq2();
// Builds on some other (undisputed) predecessor → this rule does not seal it.
assert!(!seal_rejects_extension(&report, 3, "EunrelatedHead"));
// At or below the disputed sequence is not "past" the dispute.
assert!(!seal_rejects_extension(&report, 2, "ErotA"));
assert!(!seal_rejects_extension(&report, 1, "ErotA"));
}

#[test]
fn seal_rejects_nothing_when_clean() {
assert!(!seal_rejects_extension(&DuplicityReport::Clean, 3, "ErotA"));
}
}
20 changes: 20 additions & 0 deletions crates/auths-verifier/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,26 @@ mod tests {
assert!(!report.is_valid());
}

#[test]
fn is_valid_is_false_when_a_duplicity_warning_is_present() {
// Fail-closed: a valid signature on a KEL that has forked is not trustworthy.
// The signature `status` stays Valid, but the trust decision (`is_valid`) refuses.
let report = VerificationReport::with_status(VerificationStatus::Valid, vec![])
.with_duplicity_warning(crate::duplicity::DuplicityReport::Diverging {
#[allow(clippy::disallowed_methods)]
shared_kel_prefix: crate::types::IdentityDID::new_unchecked(
"did:keri:EShared".to_string(),
),
seq: 2,
event_saids: vec!["ErotA".into(), "ErotB".into()],
});
assert!(matches!(report.status, VerificationStatus::Valid));
assert!(
!report.is_valid(),
"a diverging KEL must fail closed, not warn-and-pass"
);
}

#[test]
fn verification_report_serializes_to_expected_json() {
let chain = vec![
Expand Down
20 changes: 11 additions & 9 deletions crates/auths-verifier/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,10 @@ pub struct VerificationReport {
pub anchored: Option<auths_keri::AnchorStatus>,
/// Structured duplicity warning from the shared-KEL detector.
///
/// Fail-open: `Valid` with a `Some(Diverging { … })` warning still
/// means the attestation signature verified. The warning surfaces
/// so CLI / iOS / CI can render a banner and point the user at
/// `auths device remove` to resolve.
/// Fail-closed: a `Some(Diverging { … })` warning makes [`VerificationReport::is_valid`]
/// return `false` even though `status` still records that the attestation *signature*
/// verified — a forked KEL is not trustworthy. The structured warning also lets
/// CLI / iOS / CI render a banner and point the user at `auths device remove` to resolve.
///
/// `None` indicates no divergence was observed (or no shared-KEL
/// replay was performed).
Expand All @@ -54,9 +54,11 @@ pub struct VerificationReport {
}

impl VerificationReport {
/// Returns true only when the verification status is Valid.
/// Returns true only when the signature status is `Valid` **and** no duplicity warning is
/// present. Fail-closed: a valid signature on a KEL that has forked (`Some(Diverging)`) is not
/// trustworthy, so the trust decision refuses even though `status` records the signature as valid.
pub fn is_valid(&self) -> bool {
matches!(self.status, VerificationStatus::Valid)
matches!(self.status, VerificationStatus::Valid) && self.duplicity_warning.is_none()
}

/// Creates a new valid VerificationReport with the given chain.
Expand Down Expand Up @@ -94,9 +96,9 @@ impl VerificationReport {
}
}

/// Attach a duplicity warning to this report. Does not change `status`
/// fail-open policy: a diverging shared KEL is an orthogonal signal to
/// per-attestation signature validity.
/// Attach a duplicity warning to this report. Leaves `status` (the *signature* verdict)
/// unchanged, but makes [`VerificationReport::is_valid`] fail closed — a diverging shared
/// KEL is not trustworthy even when the per-attestation signature verified.
pub fn with_duplicity_warning(mut self, warning: crate::duplicity::DuplicityReport) -> Self {
self.duplicity_warning = Some(warning);
self
Expand Down
17 changes: 17 additions & 0 deletions crates/auths-verifier/tests/cases/freshness_honesty.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,23 @@ fn commit_valid_names_as_of_and_freshness() {
assert_eq!(verdict.as_of(), Some(2));
}

#[test]
fn duplicitous_root_is_not_trusted_even_when_fresh() {
use auths_verifier::freshness::FreshnessPolicy;
let verdict = CommitVerdict::Valid {
signer_did: "did:keri:Edev".to_string(),
root_did: "did:keri:Eroot".to_string(),
duplicitous_root: true,
as_of: 2,
freshness: Freshness::Fresh,
};
// Fail-closed: a forked root KEL is not trusted even with a fresh, valid signature —
// the relying party cannot tell which branch is real.
assert!(!verdict.is_trusted(&FreshnessPolicy::default()));
// is_valid() stays true: the signature/chain verified; only the trust gate fails closed.
assert!(verdict.is_valid());
}

#[test]
fn verification_report_valid_names_as_of_and_freshness() {
let report = VerificationReport::valid(vec![]);
Expand Down
Loading