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}