Skip to main content

zebrad/components/inbound/
downloads.rs

1//! A download stream that handles gossiped blocks from peers.
2
3#[cfg(test)]
4mod tests;
5
6use std::{
7    collections::{HashMap, HashSet},
8    net::IpAddr,
9    pin::Pin,
10    task::{Context, Poll},
11    time::Duration,
12};
13
14use futures::{
15    future::TryFutureExt,
16    ready,
17    stream::{FuturesUnordered, Stream},
18};
19use pin_project::pin_project;
20use thiserror::Error;
21use tokio::{sync::oneshot, task::JoinHandle, time::timeout};
22use tower::{Service, ServiceExt};
23use tracing_futures::Instrument;
24
25use zebra_chain::{
26    block::{self, HeightDiff},
27    chain_tip::ChainTip,
28};
29use zebra_network::{self as zn, PeerSocketAddr};
30use zebra_state as zs;
31
32use crate::components::sync::{BLOCK_DOWNLOAD_TIMEOUT, MIN_CONCURRENCY_LIMIT};
33
34type BoxError = Box<dyn std::error::Error + Send + Sync + 'static>;
35
36/// How long to wait for the parent-height lookup that decides whether a behind-tip gossiped block
37/// was forged.
38///
39/// Bounds a drop path that also fires for honest old blocks. Timing out is treated as "no proof",
40/// so a slow state read costs the supplying peer nothing.
41const PARENT_LOOKUP_TIMEOUT: Duration = Duration::from_secs(5);
42
43/// A gossiped block was dropped before verification because its coinbase height was outside the
44/// accepted range around the chain tip.
45///
46/// Peers legitimately serve blocks that are genuinely far ahead of the tip while Zebra is catching
47/// up, and blocks that are genuinely older than the finalized tip, so this error is returned for
48/// every such drop. But a peer can also answer a [`BlockDownload`](zn::Request::BlockDownload) request
49/// with a canonical header and a rewritten coinbase height, because the coinbase scriptSig is
50/// excluded from the V5 transaction ID and therefore from the block hash (ZIP-244). The initial hash
51/// check still passes, so the forged height reaches these drops before consensus validation, and the
52/// verifier never scores the peer (GHSA-4f6v-mj46-gxg3).
53///
54/// The downloader attaches the supplying peer's address to the drop only when the parent header
55/// Zebra already holds proves the claimed height wrong (see [`advertiser_if_parent_contradicts`]),
56/// so the inbound handler scores a proven rewrite, and never an authentic block.
57///
58/// This is the inbound-gossip sibling of the sync path's height limit errors (GHSA-g95h-hw6g-pvgv).
59#[derive(Copy, Clone, Debug, Error)]
60pub enum HeightLimitError {
61    /// The block's coinbase height is above the lookahead limit.
62    #[error("gossiped block height {height:?} too far ahead of the tip: {hash:?}")]
63    AboveLookahead {
64        height: block::Height,
65        hash: block::Hash,
66    },
67
68    /// The block's coinbase height is behind the finalized tip.
69    #[error("gossiped block height {height:?} behind the finalized tip: {hash:?}")]
70    BehindTip {
71        height: block::Height,
72        hash: block::Hash,
73    },
74}
75
76impl HeightLimitError {
77    /// The misbehavior score for a gossiped block whose parent proves its height was rewritten.
78    ///
79    /// A rewritten coinbase height is unambiguous misbehavior whichever limit it crossed, so score it
80    /// at the ban threshold, matching the sync path (GHSA-g95h-hw6g-pvgv).
81    pub fn misbehavior_score(&self) -> u32 {
82        zn::constants::MAX_PEER_MISBEHAVIOR_SCORE
83    }
84}
85
86/// Returns `advertiser_addr` if the parent header Zebra already holds proves that a gossiped block's
87/// claimed `block_height` was rewritten, and `None` if there is no such proof.
88///
89/// # Security
90///
91/// A peer can answer a `BlockDownload` request with a canonical header and a rewritten coinbase
92/// height, because the coinbase scriptSig is excluded from the V5 transaction ID and therefore from
93/// the block hash (ZIP-244). The hash check passes, so the forged height reaches the height limit
94/// drops before consensus validation. Peers also legitimately serve blocks that are genuinely far
95/// ahead of the tip or genuinely older than the finalized tip, so the drop is only attributed to the
96/// supplying peer when the parent header Zebra holds contradicts the claimed height: a block's height
97/// is one more than its parent's, so a held parent whose height disagrees proves the body was
98/// rewritten, whichever limit the claimed height crossed.
99///
100/// A parent Zebra does not hold, a height consistent with the parent, and a failed or timed-out
101/// lookup are all treated as no proof, so honest peers are never scored and a slow state read costs
102/// the peer nothing. (GHSA-4f6v-mj46-gxg3, the inbound-gossip sibling of GHSA-g95h-hw6g-pvgv.)
103async fn advertiser_if_parent_contradicts<ZS>(
104    state: ZS,
105    parent_hash: block::Hash,
106    block_height: block::Height,
107    advertiser_addr: Option<PeerSocketAddr>,
108) -> Option<PeerSocketAddr>
109where
110    ZS: Service<zs::Request, Response = zs::Response, Error = BoxError> + Send + Clone + 'static,
111    ZS::Future: Send,
112{
113    // There is no peer to score, so skip the state lookup.
114    let advertiser_addr = advertiser_addr?;
115
116    match timeout(
117        PARENT_LOOKUP_TIMEOUT,
118        state.oneshot(zs::Request::BlockHeader(parent_hash.into())),
119    )
120    .await
121    {
122        Ok(Ok(zs::Response::BlockHeader {
123            height: parent_height,
124            ..
125        })) if (parent_height + 1) != Some(block_height) => Some(advertiser_addr),
126        // Parent unknown, height consistent with it, or the lookup failed or timed out: there is no
127        // proof of misbehavior, so the peer is not scored.
128        _ => None,
129    }
130}
131
132/// The maximum number of concurrent inbound download and verify tasks.
133/// Also used as the maximum lookahead limit, before block verification.
134///
135/// We expect the syncer to download and verify checkpoints, so this bound
136/// can be small.
137///
138/// ## Security
139///
140/// The maximum block size is 2 million bytes. A deserialized malicious
141/// block with ~225_000 transparent outputs can take up 9MB of RAM.
142/// The total queue bound is `MAX_INBOUND_CONCURRENCY * 9 MB`. Each peer IP
143/// is limited to one in-flight download (9 MB) by the per-IP cap enforced
144/// in [`Downloads::download_and_verify`], so a sybil or IPv6-range attacker
145/// still needs many distinct source IPs to approach the total bound.
146/// (See #1880 for more details.)
147///
148/// Malicious blocks will eventually timeout or fail contextual validation.
149/// Once validation fails, the block is dropped, and its memory is deallocated.
150pub const MAX_INBOUND_CONCURRENCY: usize = 200;
151
152/// The action taken in response to a peer's gossiped block hash.
153pub enum DownloadAction {
154    /// The block hash was successfully queued for download and verification.
155    AddedToQueue,
156
157    /// The block hash is already queued, so this request was ignored.
158    ///
159    /// Another peer has already gossiped the same hash to us.
160    AlreadyQueued,
161
162    /// The queue is at capacity, so this request was ignored.
163    ///
164    /// The sync service should discover this block later, when we are closer
165    /// to the tip. The queue's capacity is [`Downloads::full_verify_concurrency_limit`].
166    FullQueue,
167
168    /// The advertising peer's IP already has an in-flight download, so
169    /// this request was ignored. Zcash's post-Blossom target block spacing
170    /// is 75 seconds, so honest peers rarely gossip more than one block
171    /// before the first is verified; during reorgs or recovery the same
172    /// hash also arrives from other peers or via the syncer.
173    TooManyFromPeer,
174}
175
176/// Manages download and verification of blocks gossiped to this peer.
177#[pin_project]
178#[derive(Debug)]
179pub struct Downloads<ZN, ZV, ZS, ZSTip>
180where
181    ZN: Service<zn::Request, Response = zn::Response, Error = BoxError> + Send + Clone + 'static,
182    ZN::Future: Send,
183    ZV: Service<zebra_consensus::Request, Response = block::Hash, Error = BoxError>
184        + Send
185        + Clone
186        + 'static,
187    ZV::Future: Send,
188    ZS: Service<zs::Request, Response = zs::Response, Error = BoxError> + Send + Clone + 'static,
189    ZS::Future: Send,
190    ZSTip: ChainTip + Clone + Send + 'static,
191{
192    // Configuration
193    //
194    /// The configured full verification concurrency limit, after applying the minimum limit.
195    full_verify_concurrency_limit: usize,
196
197    // Services
198    //
199    /// A service that forwards requests to connected peers, and returns their
200    /// responses.
201    network: ZN,
202
203    /// A service that verifies downloaded blocks.
204    verifier: ZV,
205
206    /// A service that manages cached blockchain state.
207    state: ZS,
208
209    /// Allows efficient access to the best tip of the blockchain.
210    latest_chain_tip: ZSTip,
211
212    // Internal downloads state
213    //
214    /// A list of pending block download and verify tasks.
215    #[pin]
216    pending: FuturesUnordered<
217        JoinHandle<Result<block::Hash, (BoxError, block::Hash, Option<PeerSocketAddr>)>>,
218    >,
219
220    /// Cancellation handles for tasks in [`Self::pending`], keyed by block
221    /// hash. The `Option<IpAddr>` is the advertiser IP recorded in
222    /// [`Self::in_flight_ips`], so completion can remove it by hash lookup.
223    cancel_handles: HashMap<block::Hash, (oneshot::Sender<()>, Option<IpAddr>)>,
224
225    /// Advertiser IPs with an in-flight download and verify task.
226    ///
227    /// Invariant: an IP is present iff some entry in [`Self::cancel_handles`]
228    /// has value `(_, Some(ip))`. Enforces the one-download-per-IP cap.
229    ///
230    /// Size-bounded by `full_verify_concurrency_limit` (≤ [`MAX_INBOUND_CONCURRENCY`]),
231    /// inherited from the [`DownloadAction::FullQueue`] check on
232    /// [`Self::pending`].
233    in_flight_ips: HashSet<IpAddr>,
234}
235
236impl<ZN, ZV, ZS, ZSTip> Stream for Downloads<ZN, ZV, ZS, ZSTip>
237where
238    ZN: Service<zn::Request, Response = zn::Response, Error = BoxError> + Send + Clone + 'static,
239    ZN::Future: Send,
240    ZV: Service<zebra_consensus::Request, Response = block::Hash, Error = BoxError>
241        + Send
242        + Clone
243        + 'static,
244    ZV::Future: Send,
245    ZS: Service<zs::Request, Response = zs::Response, Error = BoxError> + Send + Clone + 'static,
246    ZS::Future: Send,
247    ZSTip: ChainTip + Clone + Send + 'static,
248{
249    type Item = Result<block::Hash, (BoxError, Option<PeerSocketAddr>)>;
250
251    fn poll_next(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Option<Self::Item>> {
252        let this = self.project();
253        // CORRECTNESS
254        //
255        // The current task must be scheduled for wakeup every time we return
256        // `Poll::Pending`.
257        //
258        // If no download and verify tasks have exited since the last poll, this
259        // task is scheduled for wakeup when the next task becomes ready.
260        //
261        // TODO: this would be cleaner with poll_map (#2693)
262        if let Some(join_result) = ready!(this.pending.poll_next(cx)) {
263            let (result, hash) =
264                match join_result.expect("block download and verify tasks must not panic") {
265                    Ok(hash) => (Ok(hash), hash),
266                    Err((e, hash, advertiser_addr)) => (Err((e, advertiser_addr)), hash),
267                };
268            if let Some((_, Some(ip))) = this.cancel_handles.remove(&hash) {
269                assert!(
270                    this.in_flight_ips.remove(&ip),
271                    "every tracked IP was inserted when its download was queued",
272                );
273            }
274            Poll::Ready(Some(result))
275        } else {
276            Poll::Ready(None)
277        }
278    }
279
280    fn size_hint(&self) -> (usize, Option<usize>) {
281        self.pending.size_hint()
282    }
283}
284
285impl<ZN, ZV, ZS, ZSTip> Downloads<ZN, ZV, ZS, ZSTip>
286where
287    ZN: Service<zn::Request, Response = zn::Response, Error = BoxError> + Send + Clone + 'static,
288    ZN::Future: Send,
289    ZV: Service<zebra_consensus::Request, Response = block::Hash, Error = BoxError>
290        + Send
291        + Clone
292        + 'static,
293    ZV::Future: Send,
294    ZS: Service<zs::Request, Response = zs::Response, Error = BoxError> + Send + Clone + 'static,
295    ZS::Future: Send,
296    ZSTip: ChainTip + Clone + Send + 'static,
297{
298    /// Initialize a new download stream with the provided `network`, `verifier`, and `state` services.
299    /// The `latest_chain_tip` must be linked to the provided `state` service.
300    ///
301    /// The download deadline covers network readiness and the response. Any retry policy
302    /// should be applied to `network` without a separate timeout that includes queued requests.
303    pub fn new(
304        full_verify_concurrency_limit: usize,
305        network: ZN,
306        verifier: ZV,
307        state: ZS,
308        latest_chain_tip: ZSTip,
309    ) -> Self {
310        // The syncer already warns about the minimum.
311        let full_verify_concurrency_limit =
312            full_verify_concurrency_limit.clamp(MIN_CONCURRENCY_LIMIT, MAX_INBOUND_CONCURRENCY);
313
314        Self {
315            full_verify_concurrency_limit,
316            network,
317            verifier,
318            state,
319            latest_chain_tip,
320            pending: FuturesUnordered::new(),
321            cancel_handles: HashMap::new(),
322            in_flight_ips: HashSet::new(),
323        }
324    }
325
326    /// Queue a block for download and verification.
327    ///
328    /// When `advertiser` is `Some`, it is passed to the network as a known block source,
329    /// and its IP is tracked in [`Self::in_flight_ips`] to enforce the one-download-per-IP cap.
330    /// `None` bypasses per-IP accounting (for example when Zebra triggers the download internally).
331    #[instrument(skip(self, hash), fields(hash = %hash))]
332    pub fn download_and_verify(
333        &mut self,
334        hash: block::Hash,
335        advertiser: Option<PeerSocketAddr>,
336    ) -> DownloadAction {
337        if self.cancel_handles.contains_key(&hash) {
338            debug!(
339                ?hash,
340                queue_len = self.pending.len(),
341                concurrency_limit = self.full_verify_concurrency_limit,
342                "block hash already queued for inbound download: ignored block",
343            );
344
345            metrics::gauge!("gossip.queued.block.count").set(self.pending.len() as f64);
346            metrics::counter!("gossip.already.queued.dropped.block.hash.count").increment(1);
347
348            return DownloadAction::AlreadyQueued;
349        }
350
351        if self.pending.len() >= self.full_verify_concurrency_limit {
352            debug!(
353                ?hash,
354                queue_len = self.pending.len(),
355                concurrency_limit = self.full_verify_concurrency_limit,
356                "too many blocks queued for inbound download: ignored block",
357            );
358
359            metrics::gauge!("gossip.queued.block.count").set(self.pending.len() as f64);
360            metrics::counter!("gossip.full.queue.dropped.block.hash.count").increment(1);
361
362            return DownloadAction::FullQueue;
363        }
364
365        let advertiser_ip = advertiser.map(|addr| addr.ip());
366        if let Some(ip) = advertiser_ip {
367            if self.in_flight_ips.contains(&ip) {
368                debug!(
369                    ?hash,
370                    ?advertiser,
371                    "already have an in-flight inbound download from peer IP: ignored block",
372                );
373
374                metrics::counter!("gossip.peer.limit.dropped.block.hash.count").increment(1);
375
376                return DownloadAction::TooManyFromPeer;
377            }
378        }
379
380        // This oneshot is used to signal cancellation to the download task.
381        let (cancel_tx, mut cancel_rx) = oneshot::channel::<()>();
382
383        let state = self.state.clone();
384        let network = self.network.clone();
385        let verifier = self.verifier.clone();
386        let latest_chain_tip = self.latest_chain_tip.clone();
387        let full_verify_concurrency_limit = self.full_verify_concurrency_limit;
388
389        let fut = async move {
390            // Check if the block is already in the state.
391            match state.clone().oneshot(zs::Request::KnownBlock(hash)).await {
392                Ok(zs::Response::KnownBlock(None)) => Ok(()),
393                Ok(zs::Response::KnownBlock(Some(_))) => Err("already present".into()),
394                Ok(_) => unreachable!("wrong response"),
395                Err(e) => Err(e),
396            }
397            .map_err(|e| (e, None))?;
398
399            let response = timeout(
400                zn::constants::REQUEST_TIMEOUT + BLOCK_DOWNLOAD_TIMEOUT,
401                network.oneshot(zn::Request::BlockDownload { hash, advertiser }),
402            )
403            .await
404            .map_err(|e| (BoxError::from(e), None))?
405            .map_err(|e| (e, None))?;
406            let (block, advertiser_addr) = if let zn::Response::Blocks(blocks) = response {
407                assert_eq!(
408                    blocks.len(),
409                    1,
410                    "wrong number of blocks in response to a single hash",
411                );
412
413                blocks
414                    .first()
415                    .expect("just checked length")
416                    .available()
417                    .expect(
418                        "unexpected missing block status: single block failures should be errors",
419                    )
420            } else {
421                unreachable!("wrong response to block request");
422            };
423            metrics::counter!("gossip.downloaded.block.count").increment(1);
424
425            // # Security & Performance
426            //
427            // Reject blocks that are too far ahead of our tip,
428            // and blocks that are behind the finalized tip.
429            //
430            // Avoids denial of service attacks. Also reduces wasted work on high blocks
431            // that will timeout before being verified, and low blocks that can never be finalized.
432            let tip_height = latest_chain_tip.best_tip_height();
433
434            let max_lookahead_height = if let Some(tip_height) = tip_height {
435                let lookahead = HeightDiff::try_from(full_verify_concurrency_limit)
436                    .expect("fits in HeightDiff");
437                (tip_height + lookahead).expect("tip is much lower than Height::MAX")
438            } else {
439                let genesis_lookahead =
440                    u32::try_from(full_verify_concurrency_limit - 1).expect("fits in u32");
441                block::Height(genesis_lookahead)
442            };
443
444            // Get the finalized tip height, assuming we're using the non-finalized state.
445            //
446            // It doesn't matter if we're a few blocks off here, because blocks this low
447            // are part of a fork with much less work. So they would be rejected anyway.
448            //
449            // And if we're still checkpointing, the checkpointer will reject blocks behind
450            // the finalized tip anyway.
451            //
452            // TODO: get the actual finalized tip height
453            let min_accepted_height = tip_height
454                .map(|tip_height| {
455                    block::Height(tip_height.0.saturating_sub(zs::MAX_BLOCK_REORG_HEIGHT))
456                })
457                .unwrap_or(block::Height(0));
458
459            let block_height = block
460                .coinbase_height()
461                .ok_or_else(|| {
462                    debug!(
463                        ?hash,
464                        "gossiped block with no height: dropped downloaded block"
465                    );
466                    metrics::counter!("gossip.no.height.dropped.block.count").increment(1);
467
468                    BoxError::from("gossiped block with no height")
469                })
470                .map_err(|e| (e, None))?;
471
472            if block_height > max_lookahead_height {
473                debug!(
474                    ?hash,
475                    ?block_height,
476                    ?tip_height,
477                    ?max_lookahead_height,
478                    lookahead_limit = full_verify_concurrency_limit,
479                    "gossiped block height too far ahead of the tip: dropped downloaded block",
480                );
481                metrics::counter!("gossip.max.height.limit.dropped.block.count").increment(1);
482
483                // # Security
484                //
485                // Attribute the drop to the supplying peer only if the parent Zebra holds proves the
486                // claimed height was rewritten (GHSA-4f6v-mj46-gxg3). A genuinely far-ahead block
487                // has a parent Zebra does not hold yet, so it is dropped anonymously.
488                let advertiser_addr = advertiser_if_parent_contradicts(
489                    state,
490                    block.header.previous_block_hash,
491                    block_height,
492                    advertiser_addr,
493                )
494                .await;
495
496                return Err((
497                    BoxError::from(HeightLimitError::AboveLookahead {
498                        height: block_height,
499                        hash,
500                    }),
501                    advertiser_addr,
502                ));
503            } else if block_height < min_accepted_height {
504                debug!(
505                    ?hash,
506                    ?block_height,
507                    ?tip_height,
508                    ?min_accepted_height,
509                    behind_tip_limit = ?zs::MAX_BLOCK_REORG_HEIGHT,
510                    "gossiped block height behind the finalized tip: dropped downloaded block",
511                );
512                metrics::counter!("gossip.min.height.limit.dropped.block.count").increment(1);
513
514                // # Security
515                //
516                // Attribute the drop to the supplying peer only if the parent Zebra holds proves the
517                // claimed height was rewritten (GHSA-4f6v-mj46-gxg3). A genuinely old block is
518                // consistent with its parent, or has a parent Zebra does not hold, so it is dropped
519                // anonymously.
520                let advertiser_addr = advertiser_if_parent_contradicts(
521                    state,
522                    block.header.previous_block_hash,
523                    block_height,
524                    advertiser_addr,
525                )
526                .await;
527
528                return Err((
529                    BoxError::from(HeightLimitError::BehindTip {
530                        height: block_height,
531                        hash,
532                    }),
533                    advertiser_addr,
534                ));
535            }
536
537            verifier
538                .oneshot(zebra_consensus::Request::Commit(block))
539                .await
540                .map(|hash| (hash, block_height))
541                .map_err(|e| (e, advertiser_addr))
542        }
543        .map_ok(|(hash, height)| {
544            info!(?height, "downloaded and verified gossiped block");
545            metrics::counter!("gossip.verified.block.count").increment(1);
546            hash
547        })
548        // Tack the hash onto the error so poll_next can look up the cancel
549        // handle and advertising IP on failure as well as success.
550        .map_err(move |(e, advertiser_addr)| (e, hash, advertiser_addr))
551        .in_current_span();
552
553        let task = tokio::spawn(async move {
554            // Prefer the cancel handle if both are ready.
555            tokio::select! {
556                biased;
557                _ = &mut cancel_rx => {
558                    trace!("task cancelled prior to completion");
559                    metrics::counter!("gossip.cancelled.count").increment(1);
560                    Err(("canceled".into(), hash, None))
561                }
562                verification = fut => verification,
563            }
564        });
565
566        self.pending.push(task);
567        assert!(
568            self.cancel_handles
569                .insert(hash, (cancel_tx, advertiser_ip))
570                .is_none(),
571            "blocks are only queued once"
572        );
573        if let Some(ip) = advertiser_ip {
574            assert!(
575                self.in_flight_ips.insert(ip),
576                "the per-IP cap check above rejects any IP already in flight",
577            );
578        }
579
580        debug!(
581            ?hash,
582            queue_len = self.pending.len(),
583            concurrency_limit = self.full_verify_concurrency_limit,
584            "queued hash for download",
585        );
586        metrics::gauge!("gossip.queued.block.count").set(self.pending.len() as f64);
587
588        DownloadAction::AddedToQueue
589    }
590}