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}