tendermint_light_client_detector/detect.rs
1use std::{thread, time::Duration};
2
3use tracing::{debug, warn};
4
5use tendermint::{block::signed_header::SignedHeader, crypto::Sha256, merkle::MerkleHash};
6use tendermint_light_client::light_client::TargetOrLatest;
7use tendermint_light_client::verifier::errors::ErrorExt;
8use tendermint_light_client::verifier::types::LightBlock;
9
10use crate::conflict::GatheredEvidence;
11
12use super::{
13 error::Error, gather_evidence_from_conflicting_headers, provider::Provider, trace::Trace,
14};
15
16/// A divergence between the primary and a witness that has been detected in [`detect_divergence`].
17#[derive(Clone, Debug)]
18pub struct Divergence {
19 /// The evidence of a misbehaviour that has been gathered from the conflicting headers
20 pub evidence: GatheredEvidence,
21 /// The conflicting light block that was returned by the witness
22 pub challenging_block: LightBlock,
23}
24
25/// Given a primary trace and a witness, detect any divergence between the two,
26/// by querying the witness for the same header as the last header in the primary trace
27/// (ie. the target block), and comparing the hashes.
28///
29/// If the hashes match, then no divergence has been detected and the target block can be trusted.
30///
31/// If the hashes do not match, then the witness has provided a conflicting header.
32/// This could possibly imply an attack on the light client.
33/// In this case, we need to verify the witness's header using the same skipping verification
34/// and then we need to find the point that the headers diverge and examine this for any evidence of
35/// an attack. We then attempt to find the bifurcation point and if successful construct the
36/// evidence of an attack to report to the witness.
37pub async fn detect_divergence<H>(
38 primary: Option<&Provider>,
39 witness: &mut Provider,
40 primary_trace: Vec<LightBlock>,
41 max_clock_drift: Duration,
42 max_block_lag: Duration,
43) -> Result<Option<Divergence>, Error>
44where
45 H: Sha256 + MerkleHash + Default,
46{
47 let primary_trace = Trace::new(primary_trace)?;
48
49 let last_verified_block = primary_trace.last();
50 let last_verified_header = &last_verified_block.signed_header;
51
52 debug!(
53 end_block_height = %last_verified_header.header.height,
54 end_block_hash = %last_verified_header.header.hash(),
55 length = primary_trace.len(),
56 "Running detector against primary trace"
57 );
58
59 let result = compare_new_header_with_witness(
60 last_verified_header,
61 witness,
62 max_clock_drift,
63 max_block_lag,
64 );
65
66 match result {
67 // No divergence found
68 Ok(()) => Ok(None),
69
70 // We have conflicting headers. This could possibly imply an attack on the light client.
71 // First we need to verify the witness's header using the same skipping verification and then we
72 // need to find the point that the headers diverge and examine this for any evidence of an attack.
73 //
74 // We combine these actions together, verifying the witnesses headers and outputting the trace
75 // which captures the bifurcation point and if successful provides the information to create valid evidence.
76 Err(CompareError::ConflictingHeaders(challenging_block)) => {
77 warn!(
78 witness = %witness.peer_id(),
79 height = %challenging_block.height(),
80 "Found conflicting headers between primary and witness"
81 );
82
83 // Gather the evidence to report from the conflicting headers
84 let evidence = gather_evidence_from_conflicting_headers::<H>(
85 primary,
86 witness,
87 &primary_trace,
88 &challenging_block,
89 )
90 .await?;
91
92 Ok(Some(Divergence {
93 evidence,
94 challenging_block: *challenging_block,
95 }))
96 },
97
98 Err(CompareError::BadWitness) => {
99 // These are all melevolent errors and should result in removing the witness
100 debug!(witness = %witness.peer_id(), "witness returned an error during header comparison, removing...");
101
102 Err(Error::bad_witness())
103 },
104
105 Err(CompareError::Other(e)) => {
106 // Benign errors which can be ignored
107 debug!(witness = %witness.peer_id(), "error in light block request to witness: {e}");
108
109 Err(Error::light_client(e))
110 },
111 }
112}
113
114/// An error that arised when comparing a header from the primary with a header from a witness
115/// with [`compare_new_header_with_witness`].
116#[derive(Debug)]
117pub enum CompareError {
118 /// There may have been an attack on this light client
119 ConflictingHeaders(Box<LightBlock>),
120 /// The witness has either not responded, doesn't have the header or has given us an invalid one
121 BadWitness,
122 /// Some other error has occurred, this is likely a benign error
123 Other(tendermint_light_client::errors::Error),
124}
125
126/// Takes the verified header from the primary and compares it with a
127/// header from a specified witness. The function can return one of three errors:
128///
129/// 1: `CompareError::ConflictingHeaders`: there may have been an attack on this light client
130/// 2: `CompareError::BadWitness`: the witness has either not responded, doesn't have the header or has given us an invalid one
131/// 3: `CompareError::Other`: some other error has occurred, this is likely a benign error
132///
133/// Note: In the case of an invalid header we remove the witness
134///
135/// 3: nil -> the hashes of the two headers match
136pub fn compare_new_header_with_witness(
137 new_header: &SignedHeader,
138 witness: &mut Provider,
139 max_clock_drift: Duration,
140 max_block_lag: Duration,
141) -> Result<(), CompareError> {
142 let light_block = check_against_witness(new_header, witness, max_clock_drift, max_block_lag)?;
143
144 if light_block.signed_header.header.hash() != new_header.header.hash() {
145 return Err(CompareError::ConflictingHeaders(Box::new(light_block)));
146 }
147
148 Ok(())
149}
150
151fn check_against_witness(
152 sh: &SignedHeader,
153 witness: &mut Provider,
154 max_clock_drift: Duration,
155 max_block_lag: Duration,
156) -> Result<LightBlock, CompareError> {
157 let _span =
158 tracing::debug_span!("check_against_witness", witness = %witness.peer_id()).entered();
159
160 let light_block = witness.fetch_light_block(sh.header.height);
161
162 match light_block {
163 // No error means we move on to checking the hash of the two headers
164 Ok(lb) => Ok(lb),
165
166 // The witness hasn't been helpful in comparing headers, we mark the response and continue
167 // comparing with the rest of the witnesses
168 Err(e) if e.detail().is_io() => {
169 debug!("The witness hasn't been helpful in comparing headers");
170
171 Err(CompareError::BadWitness)
172 },
173
174 // The witness' head of the blockchain is lower than the height of the primary.
175 // This could be one of two things:
176 // 1) The witness is lagging behind
177 // 2) The primary may be performing a lunatic attack with a height and time in the future
178 Err(e) if e.detail().is_height_too_high() => {
179 debug!("The witness' head of the blockchain is lower than the height of the primary");
180
181 let light_block = witness
182 .get_target_block_or_latest(sh.header.height)
183 .map_err(|_| CompareError::BadWitness)?;
184
185 let light_block = match light_block {
186 // If the witness caught up and has returned a block of the target height then we can
187 // break from this switch case and continue to verify the hashes
188 TargetOrLatest::Target(light_block) => return Ok(light_block),
189
190 // Otherwise we continue with the checks
191 TargetOrLatest::Latest(light_block) => light_block,
192 };
193
194 // The witness' last header is below the primary's header.
195 // We check the times to see if the blocks have conflicting times
196 debug!("The witness' last header is below the primary's header");
197
198 if !light_block.time().before(sh.header.time) {
199 return Err(CompareError::ConflictingHeaders(Box::new(light_block)));
200 }
201
202 // The witness is behind. We wait for a period WAITING = 2 * DRIFT + LAG.
203 // This should give the witness ample time if it is a participating member
204 // of consensus to produce a block that has a time that is after the primary's
205 // block time. If not the witness is too far behind and the light client removes it
206 let wait_time = 2 * max_clock_drift + max_block_lag;
207 debug!("The witness is behind. We wait for {wait_time:?}");
208
209 thread::sleep(wait_time);
210
211 let light_block = witness
212 .get_target_block_or_latest(sh.header.height)
213 .map_err(|_| CompareError::BadWitness)?;
214
215 let light_block = match light_block {
216 // If the witness caught up and has returned a block of the target height then we can
217 // return and continue to verify the hashes
218 TargetOrLatest::Target(light_block) => return Ok(light_block),
219
220 // Otherwise we continue with the checks
221 TargetOrLatest::Latest(light_block) => light_block,
222 };
223
224 // The witness still doesn't have a block at the height of the primary.
225 // Check if there is a conflicting time
226 if !light_block.time().before(sh.header.time) {
227 return Err(CompareError::ConflictingHeaders(Box::new(light_block)));
228 }
229
230 // Following this request response procedure, the witness has been unable to produce a block
231 // that can somehow conflict with the primary's block. We thus conclude that the witness
232 // is too far behind and thus we return an error.
233 //
234 // NOTE: If the clock drift / lag has been miscalibrated it is feasible that the light client has
235 // drifted too far ahead for any witness to be able provide a comparable block and thus may allow
236 // for a malicious primary to attack it
237 Err(CompareError::BadWitness)
238 },
239
240 Err(other) => Err(CompareError::Other(other)),
241 }
242}