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#[cfg(test)]
117mod tests {
118    use super::*;
119
120    fn ev<'a>(prefix: &'a str, seq: u64, said: &'a str) -> KelEventRef<'a> {
121        KelEventRef { prefix, seq, said }
122    }
123
124    #[test]
125    fn empty_is_clean() {
126        assert_eq!(detect_duplicity(&[]), DuplicityReport::Clean);
127    }
128
129    #[test]
130    fn single_event_is_clean() {
131        let events = vec![ev("did:keri:E1", 0, "EaaaA")];
132        assert_eq!(detect_duplicity(&events), DuplicityReport::Clean);
133    }
134
135    #[test]
136    fn sequential_rots_are_clean() {
137        // Different seq values are not duplicity — that's a normal chain.
138        let events = vec![
139            ev("did:keri:E1", 0, "EincpA"),
140            ev("did:keri:E1", 1, "ErotA"),
141            ev("did:keri:E1", 2, "ErotB"),
142        ];
143        assert_eq!(detect_duplicity(&events), DuplicityReport::Clean);
144    }
145
146    #[test]
147    fn identical_said_at_same_seq_is_clean() {
148        // Replicated event (same SAID seen twice — e.g., network redelivery)
149        // must not trigger duplicity.
150        let events = vec![ev("did:keri:E1", 2, "Erot"), ev("did:keri:E1", 2, "Erot")];
151        assert_eq!(detect_duplicity(&events), DuplicityReport::Clean);
152    }
153
154    #[test]
155    fn two_different_saids_at_same_seq_is_diverging() {
156        let events = vec![
157            ev("did:keri:E1", 0, "Eincp"),
158            ev("did:keri:E1", 2, "ErotA"),
159            ev("did:keri:E1", 2, "ErotB"),
160        ];
161        match detect_duplicity(&events) {
162            DuplicityReport::Diverging {
163                shared_kel_prefix,
164                seq,
165                event_saids,
166            } => {
167                assert_eq!(shared_kel_prefix.as_str(), "did:keri:E1");
168                assert_eq!(seq, 2);
169                assert_eq!(event_saids, vec!["ErotA".to_string(), "ErotB".to_string()]);
170            }
171            DuplicityReport::Clean => panic!("expected Diverging"),
172        }
173    }
174
175    #[test]
176    fn three_way_fork_reports_all_saids() {
177        let events = vec![
178            ev("did:keri:E1", 2, "ErotA"),
179            ev("did:keri:E1", 2, "ErotB"),
180            ev("did:keri:E1", 2, "ErotC"),
181        ];
182        match detect_duplicity(&events) {
183            DuplicityReport::Diverging { event_saids, .. } => {
184                assert_eq!(event_saids.len(), 3);
185            }
186            _ => panic!("expected three-way divergence"),
187        }
188    }
189
190    #[test]
191    fn icp_only_stream_is_clean() {
192        let events = vec![ev("did:keri:E1", 0, "Eincp")];
193        assert_eq!(detect_duplicity(&events), DuplicityReport::Clean);
194    }
195
196    #[test]
197    fn diverging_report_is_diverging() {
198        let report = DuplicityReport::Diverging {
199            #[allow(clippy::disallowed_methods)]
200            shared_kel_prefix: IdentityDID::new_unchecked("did:keri:E1".to_string()),
201            seq: 2,
202            event_saids: vec!["Ea".into(), "Eb".into()],
203        };
204        assert!(report.is_diverging());
205        assert!(!DuplicityReport::Clean.is_diverging());
206    }
207}