From 5db826d90012fe679587e445964b53cfce94ef8e Mon Sep 17 00:00:00 2001 From: bordumb Date: Tue, 30 Jun 2026 03:03:23 +0100 Subject: [PATCH 1/3] feat(verifier): seal-bounding refuses extending a disputed KEL position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A candidate event that builds past a detected divergence — its sequence is beyond the fork and its predecessor is one of the conflicting heads — is now refused by `seal_rejects_extension`, so an equivocator gains nothing durable from a fork it created, even before global detection converges. Pure decision function ported from the timeline_proof harness (vdti::seal_rejects_extension), with RED-first unit tests and a runnable doctest; wiring it into the produce/verify path is a follow-up unit. Property 5 of the identity/duplicity plan. Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Anchor-Seq: 1 --- crates/auths-verifier/src/duplicity.rs | 70 ++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/crates/auths-verifier/src/duplicity.rs b/crates/auths-verifier/src/duplicity.rs index 847358a7..b2ed79b5 100644 --- a/crates/auths-verifier/src/duplicity.rs +++ b/crates/auths-verifier/src/duplicity.rs @@ -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::*; @@ -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")); + } } From f0a9015829282f6bdd0172642e1940f1398840e5 Mon Sep 17 00:00:00 2001 From: bordumb Date: Tue, 30 Jun 2026 20:59:52 +0100 Subject: [PATCH 2/3] feat(verifier): fail closed on a duplicitous KEL at the verdict surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `VerificationReport::is_valid()` now returns false when a duplicity warning is present — a valid signature on a KEL that has forked is not trustworthy, even though `status` still records the signature as valid. The three fail-open doc comments are flipped to fail-closed accordingly. Encodes the fail-closed policy at the report level with zero regression today (no production path populates the warning yet, and credential verify already fails closed via IssuerKelDuplicitous); it bites the moment detection is wired into the chain-verify input. Property 4 of the identity/duplicity plan. Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Anchor-Seq: 1 --- crates/auths-verifier/src/lib.rs | 20 ++++++++++++++++++++ crates/auths-verifier/src/types.rs | 20 +++++++++++--------- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/crates/auths-verifier/src/lib.rs b/crates/auths-verifier/src/lib.rs index 50527daf..cd4bd628 100644 --- a/crates/auths-verifier/src/lib.rs +++ b/crates/auths-verifier/src/lib.rs @@ -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![ diff --git a/crates/auths-verifier/src/types.rs b/crates/auths-verifier/src/types.rs index 3140448d..a95ef968 100644 --- a/crates/auths-verifier/src/types.rs +++ b/crates/auths-verifier/src/types.rs @@ -28,10 +28,10 @@ pub struct VerificationReport { pub anchored: Option, /// 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). @@ -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. @@ -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 From 7f059ec30ec20331cdc18487d79124d2704e7a53 Mon Sep 17 00:00:00 2001 From: bordumb Date: Tue, 30 Jun 2026 21:29:44 +0100 Subject: [PATCH 3/3] feat(verifier,sdk,cli): fail closed on a duplicitous root KEL across the commit-trust path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `CommitVerdict::is_trusted()` — the shared trust primitive consumed by both the SDK commit-trust gate and the CLI verify-commit / artifact paths — now returns false when the root KEL is duplicitous (forked). A relying party cannot tell which branch is real, so a fork fails closed even with a valid, fresh signature. The verifier still reports the fact (`duplicitous_root`); the trust call is made once, in the shared primitive. - verifier: is_trusted() fail-closed on duplicitous_root (+ test). - sdk: CommitDecision::is_authorized() inherits it via is_trusted (no duplicated logic); test pins the SDK contract. - cli: verify-commit reports duplicity as the failure reason instead of a "trusting the first event seen" warning; artifact verify gates on is_trusted, not is_valid. Property 4 (fail-closed) of the identity/duplicity plan — the commit-path teeth. Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Anchor-Seq: 1 --- .../auths-cli/src/commands/artifact/verify.rs | 3 +- .../auths-cli/src/commands/verify_commit.rs | 30 ++++++++-------- .../auths-sdk/src/workflows/commit_trust.rs | 34 +++++++++++++++++-- crates/auths-verifier/src/commit_kel.rs | 16 +++++++-- .../tests/cases/freshness_honesty.rs | 17 ++++++++++ 5 files changed, 80 insertions(+), 20 deletions(-) diff --git a/crates/auths-cli/src/commands/artifact/verify.rs b/crates/auths-cli/src/commands/artifact/verify.rs index e2d2c40e..c0760cd8 100644 --- a/crates/auths-cli/src/commands/artifact/verify.rs +++ b/crates/auths-cli/src/commands/artifact/verify.rs @@ -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::{ @@ -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:?}"); diff --git a/crates/auths-cli/src/commands/verify_commit.rs b/crates/auths-cli/src/commands/verify_commit.rs index fbbf8931..da121a6a 100644 --- a/crates/auths-cli/src/commands/verify_commit.rs +++ b/crates/auths-cli/src/commands/verify_commit.rs @@ -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()); @@ -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 { @@ -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 ); } diff --git a/crates/auths-sdk/src/workflows/commit_trust.rs b/crates/auths-sdk/src/workflows/commit_trust.rs index 7b4c4d19..53bf3ce4 100644 --- a/crates/auths-sdk/src/workflows/commit_trust.rs +++ b/crates/auths-sdk/src/workflows/commit_trust.rs @@ -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; } @@ -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" + ); + } } diff --git a/crates/auths-verifier/src/commit_kel.rs b/crates/auths-verifier/src/commit_kel.rs index 78d350c4..3e642871 100644 --- a/crates/auths-verifier/src/commit_kel.rs +++ b/crates/auths-verifier/src/commit_kel.rs @@ -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()) } diff --git a/crates/auths-verifier/tests/cases/freshness_honesty.rs b/crates/auths-verifier/tests/cases/freshness_honesty.rs index 0be0d3e3..9f7d160c 100644 --- a/crates/auths-verifier/tests/cases/freshness_honesty.rs +++ b/crates/auths-verifier/tests/cases/freshness_honesty.rs @@ -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![]);