Skip to main content

auths_verifier/
duplicity.rs

1//! Detect diverging rotation events on a shared identity KEL.
2//!
3//! With a `kt=1` controller set and no witnesses, two controllers can each
4//! sign a valid `rot` at the same sequence number independently. Both
5//! rotations are cryptographically valid — there is no single source of
6//! truth that orders them. Verifiers treat the KEL as duplicitous and
7//! surface the conflict; the user resolves it out-of-band (typically by
8//! running `auths device remove` on whichever side they trust).
9//!
10//! This module is read-only: it inspects a replay stream and reports
11//! whether divergence is present. It never mutates state or rejects
12//! otherwise-valid signatures. Callers decide policy (fail-open with a
13//! warning, fail-closed, etc.) — the structured report is the contract.
14
15use std::collections::HashMap;
16
17use crate::types::IdentityDID;
18
19/// A descriptor of one event as seen in a local replay stream.
20///
21/// Two events collide if they share `prefix` + `seq` but differ in
22/// `said`. Same-SAID collisions are replicas of the same event and
23/// never indicate duplicity.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct KelEventRef<'a> {
26    /// The shared-KEL prefix (`did:keri:E…`) the event belongs to.
27    pub prefix: &'a str,
28    /// The event sequence number.
29    pub seq: u64,
30    /// The event's self-addressing identifier (the `d` field).
31    pub said: &'a str,
32}
33
34/// Output of [`detect_duplicity`].
35#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
36#[serde(tag = "type")]
37pub enum DuplicityReport {
38    /// No conflicting events seen.
39    Clean,
40    /// Two or more events at the same `seq` have different SAIDs.
41    Diverging {
42        /// The shared-KEL prefix the conflict is on.
43        shared_kel_prefix: IdentityDID,
44        /// The sequence number where divergence starts.
45        seq: u64,
46        /// The conflicting event SAIDs, in the order they were observed.
47        event_saids: Vec<String>,
48    },
49}
50
51impl DuplicityReport {
52    /// `true` when the report represents an actual divergence.
53    pub fn is_diverging(&self) -> bool {
54        matches!(self, DuplicityReport::Diverging { .. })
55    }
56}
57
58/// Scan `events` for same-prefix same-seq events with differing SAIDs.
59///
60/// Returns the first divergence found (lowest `seq`). Callers that need
61/// all divergences can filter the input and call repeatedly; for the
62/// Stage-1 UX path — single warning banner — first-wins is enough.
63///
64/// Args:
65/// * `events`: Events to scan. Can be any subset / order; duplicates of
66///   the same SAID (i.e., replicas) are tolerated.
67///
68/// Usage:
69/// ```
70/// use auths_verifier::duplicity::{KelEventRef, detect_duplicity, DuplicityReport};
71///
72/// let events = vec![
73///     KelEventRef { prefix: "did:keri:EShared", seq: 1, said: "Ea" },
74///     KelEventRef { prefix: "did:keri:EShared", seq: 2, said: "Eb" },
75///     KelEventRef { prefix: "did:keri:EShared", seq: 2, said: "Ec" },
76/// ];
77/// match detect_duplicity(&events) {
78///     DuplicityReport::Diverging { seq, .. } => assert_eq!(seq, 2),
79///     _ => panic!("expected divergence"),
80/// }
81/// ```
82pub fn detect_duplicity(events: &[KelEventRef<'_>]) -> DuplicityReport {
83    // Group observed SAIDs by (prefix, seq) while preserving first-seen
84    // order so the report deterministically returns SAIDs in the order
85    // they appeared in the input.
86    let mut seen: HashMap<(String, u64), Vec<String>> = HashMap::new();
87    let mut first_conflict: Option<(String, u64)> = None;
88
89    for ev in events {
90        let key = (ev.prefix.to_string(), ev.seq);
91        let saids = seen.entry(key.clone()).or_default();
92        if !saids.iter().any(|s| s == ev.said) {
93            saids.push(ev.said.to_string());
94            if saids.len() >= 2 && first_conflict.is_none() {
95                first_conflict = Some(key);
96            }
97        }
98    }
99
100    if let Some((prefix, seq)) = first_conflict {
101        let saids = seen.remove(&(prefix.clone(), seq)).unwrap_or_default();
102        let shared_kel_prefix = {
103            #[allow(clippy::disallowed_methods)]
104            IdentityDID::new_unchecked(prefix)
105        };
106        return DuplicityReport::Diverging {
107            shared_kel_prefix,
108            seq,
109            event_saids: saids,
110        };
111    }
112
113    DuplicityReport::Clean
114}
115
116/// Seal-bounding: `true` when `candidate` builds *past* a disputed position — its
117/// sequence is beyond the divergence and its predecessor is one of the conflicting
118/// heads. A seal-bounded producer or verifier refuses such an extension, so an
119/// equivocator gains nothing durable from a fork it created, even before global
120/// detection converges. A `Clean` report seals nothing. Ported from the `timeline_proof`
121/// harness (`vdti::seal_rejects_extension`).
122///
123/// Args:
124/// * `report`: The divergence that froze the position.
125/// * `candidate_seq`: The sequence number of the event being appended.
126/// * `candidate_prev_said`: The SAID the candidate links back to (its `p` field).
127///
128/// Usage:
129/// ```
130/// use auths_verifier::duplicity::{KelEventRef, detect_duplicity, seal_rejects_extension};
131///
132/// let events = vec![
133///     KelEventRef { prefix: "did:keri:EShared", seq: 2, said: "ErotA" },
134///     KelEventRef { prefix: "did:keri:EShared", seq: 2, said: "ErotB" },
135/// ];
136/// let report = detect_duplicity(&events);
137/// assert!(seal_rejects_extension(&report, 3, "ErotA"));      // builds past the fork → refused
138/// assert!(!seal_rejects_extension(&report, 3, "Eunrelated")); // unrelated predecessor → allowed
139/// ```
140pub fn seal_rejects_extension(
141    report: &DuplicityReport,
142    candidate_seq: u64,
143    candidate_prev_said: &str,
144) -> bool {
145    match report {
146        DuplicityReport::Diverging {
147            seq, event_saids, ..
148        } => candidate_seq > *seq && event_saids.iter().any(|s| s == candidate_prev_said),
149        DuplicityReport::Clean => false,
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    fn ev<'a>(prefix: &'a str, seq: u64, said: &'a str) -> KelEventRef<'a> {
158        KelEventRef { prefix, seq, said }
159    }
160
161    #[test]
162    fn empty_is_clean() {
163        assert_eq!(detect_duplicity(&[]), DuplicityReport::Clean);
164    }
165
166    #[test]
167    fn single_event_is_clean() {
168        let events = vec![ev("did:keri:E1", 0, "EaaaA")];
169        assert_eq!(detect_duplicity(&events), DuplicityReport::Clean);
170    }
171
172    #[test]
173    fn sequential_rots_are_clean() {
174        // Different seq values are not duplicity — that's a normal chain.
175        let events = vec![
176            ev("did:keri:E1", 0, "EincpA"),
177            ev("did:keri:E1", 1, "ErotA"),
178            ev("did:keri:E1", 2, "ErotB"),
179        ];
180        assert_eq!(detect_duplicity(&events), DuplicityReport::Clean);
181    }
182
183    #[test]
184    fn identical_said_at_same_seq_is_clean() {
185        // Replicated event (same SAID seen twice — e.g., network redelivery)
186        // must not trigger duplicity.
187        let events = vec![ev("did:keri:E1", 2, "Erot"), ev("did:keri:E1", 2, "Erot")];
188        assert_eq!(detect_duplicity(&events), DuplicityReport::Clean);
189    }
190
191    #[test]
192    fn two_different_saids_at_same_seq_is_diverging() {
193        let events = vec![
194            ev("did:keri:E1", 0, "Eincp"),
195            ev("did:keri:E1", 2, "ErotA"),
196            ev("did:keri:E1", 2, "ErotB"),
197        ];
198        match detect_duplicity(&events) {
199            DuplicityReport::Diverging {
200                shared_kel_prefix,
201                seq,
202                event_saids,
203            } => {
204                assert_eq!(shared_kel_prefix.as_str(), "did:keri:E1");
205                assert_eq!(seq, 2);
206                assert_eq!(event_saids, vec!["ErotA".to_string(), "ErotB".to_string()]);
207            }
208            DuplicityReport::Clean => panic!("expected Diverging"),
209        }
210    }
211
212    #[test]
213    fn three_way_fork_reports_all_saids() {
214        let events = vec![
215            ev("did:keri:E1", 2, "ErotA"),
216            ev("did:keri:E1", 2, "ErotB"),
217            ev("did:keri:E1", 2, "ErotC"),
218        ];
219        match detect_duplicity(&events) {
220            DuplicityReport::Diverging { event_saids, .. } => {
221                assert_eq!(event_saids.len(), 3);
222            }
223            _ => panic!("expected three-way divergence"),
224        }
225    }
226
227    #[test]
228    fn icp_only_stream_is_clean() {
229        let events = vec![ev("did:keri:E1", 0, "Eincp")];
230        assert_eq!(detect_duplicity(&events), DuplicityReport::Clean);
231    }
232
233    #[test]
234    fn concurrent_rotation_is_detected_or_prevented() {
235        // The documented kt=1 accepted risk: with a single-signer shared KEL, two
236        // controllers can each author a rotation at the same sequence, forking the KEL.
237        // Authoring does not prevent it, so it must be surfaced — detect_duplicity flags
238        // the two competing heads rather than silently accepting both as valid.
239        let events = vec![
240            ev("did:keri:EShared", 0, "Eicp"),
241            ev("did:keri:EShared", 1, "Erot1"),
242            ev("did:keri:EShared", 2, "ErotControllerA"),
243            ev("did:keri:EShared", 2, "ErotControllerB"),
244        ];
245        match detect_duplicity(&events) {
246            DuplicityReport::Diverging {
247                shared_kel_prefix,
248                seq,
249                event_saids,
250            } => {
251                assert_eq!(shared_kel_prefix.as_str(), "did:keri:EShared");
252                assert_eq!(seq, 2, "divergence is at the concurrent rotation");
253                assert_eq!(event_saids.len(), 2);
254            }
255            DuplicityReport::Clean => {
256                panic!("concurrent rotations at the same sequence must be flagged, not accepted")
257            }
258        }
259    }
260
261    #[test]
262    fn diverging_report_is_diverging() {
263        let report = DuplicityReport::Diverging {
264            #[allow(clippy::disallowed_methods)]
265            shared_kel_prefix: IdentityDID::new_unchecked("did:keri:E1".to_string()),
266            seq: 2,
267            event_saids: vec!["Ea".into(), "Eb".into()],
268        };
269        assert!(report.is_diverging());
270        assert!(!DuplicityReport::Clean.is_diverging());
271    }
272
273    fn diverging_at_seq2() -> DuplicityReport {
274        DuplicityReport::Diverging {
275            #[allow(clippy::disallowed_methods)]
276            shared_kel_prefix: IdentityDID::new_unchecked("did:keri:EShared".to_string()),
277            seq: 2,
278            event_saids: vec!["ErotA".into(), "ErotB".into()],
279        }
280    }
281
282    #[test]
283    fn seal_rejects_extension_built_on_a_disputed_head() {
284        // An equivocator forks at seq 2, then tries to advance one branch at seq 3.
285        // Seal-bounding refuses it — building past a disputed position gains nothing durable.
286        let report = diverging_at_seq2();
287        assert!(seal_rejects_extension(&report, 3, "ErotA"));
288        assert!(seal_rejects_extension(&report, 3, "ErotB"));
289    }
290
291    #[test]
292    fn seal_allows_extension_not_descending_from_the_dispute() {
293        let report = diverging_at_seq2();
294        // Builds on some other (undisputed) predecessor → this rule does not seal it.
295        assert!(!seal_rejects_extension(&report, 3, "EunrelatedHead"));
296        // At or below the disputed sequence is not "past" the dispute.
297        assert!(!seal_rejects_extension(&report, 2, "ErotA"));
298        assert!(!seal_rejects_extension(&report, 1, "ErotA"));
299    }
300
301    #[test]
302    fn seal_rejects_nothing_when_clean() {
303        assert!(!seal_rejects_extension(&DuplicityReport::Clean, 3, "ErotA"));
304    }
305}