LCOV - code coverage report
Current view: top level - disco/shred - fd_fec_samp.h (source / functions) Hit Total Coverage
Test: cov.lcov Lines: 1 76 1.3 %
Date: 2026-11-11 12:40:43 Functions: 1 12 8.3 %

          Line data    Source code
       1             : #ifndef HEADER_fd_src_disco_shred_fd_fec_samp_h
       2             : #define HEADER_fd_src_disco_shred_fd_fec_samp_h
       3             : 
       4             : 
       5             : /* This header defines a few methods for digesting a FEC set into a
       6             :    small hash and then probabilistically answering if a given shred
       7             :    might have been part of that FEC set using the hash.  These are
       8             :    designed for use by the FEC resolver only, but are factored out into
       9             :    this file to make them easier to iterate and test.
      10             : 
      11             :    Doing this probabilistically might seem excessive, but when Turbine
      12             :    is working perfectly, we expect 32 shreds to arrive after the FEC set
      13             :    is in the done_map, which means we expect to answer "was shred X
      14             :    probably part of FEC set Y?" 32 times per FEC set.  If we derive the
      15             :    Merkle root for shred X, which is the way to answer the question
      16             :    deterministically, that's about 40us of wasted computation per FEC
      17             :    set, which is a sizable fraction of the total computation per FEC
      18             :    set.  Plus, the answer to the question is almost always "yes" unless
      19             :    the leader is malicious and equivocating. */
      20             : 
      21             : FD_PROTOTYPES_BEGIN
      22             : 
      23             : /* fd_fec_samp_t is an opaque small type that contains the digest of a
      24             :    FEC set.  It's only 4 bytes right now and it is statically sized, so
      25             :    it can be declared on the stack or included in a struct. */
      26             : union fd_fec_samp;
      27             : typedef union fd_fec_samp fd_fec_samp_t;
      28             : 
      29             : /* fd_fec_samp_derive creates a digest of a FEC set and populates s with
      30             :    it.  s must point to a valid fd_fec_samp_t.  seed is an arbitrary
      31             :    value, intended to be different per-validator.  slot, fec_set_idx,
      32             :    signature, and root all describe the FEC set that is being digested.
      33             :    bmtree must be a valid tree initialized with all the shreds in the
      34             :    FEC set as leaves. */
      35             : static inline void
      36             : fd_fec_samp_derive( fd_fec_samp_t *            s,
      37             :                     ulong                      seed,
      38             :                     ulong                      slot,
      39             :                     uint                       fec_set_idx,
      40             :                     void const *               signature,
      41             :                     fd_bmtree_node_t   const * root,
      42             :                     fd_bmtree_commit_t const * bmtree );
      43             : 
      44             : /* fd_fec_samp_set_force_match modifies the digest s to match any and
      45             :    all shreds.  This is useful for cases where you have already detected
      46             :    equivocation and do not want to spend any more computation on the FEC
      47             :    set. */
      48             : static inline void fd_fec_samp_set_force_match( fd_fec_samp_t * s );
      49             : 
      50             : 
      51             : /* fd_fec_samp_matches answers queries of "is it likely that the shred
      52             :    pointed to by shred comes from the same FEC set (identified by Merkle
      53             :    root) whose digest is stored in s?"  Returns 1 if so, and 0 if it is
      54             :    certain the shred did not come from the digest in s. seed must be the
      55             :    same value provided to fd_fec_samp_derive.  It's expected that s has
      56             :    the same slot and fec_set_idx as the FEC set whose digest is stored
      57             :    in s, but that is not checked.  IMPORTANT: This function is
      58             :    probabilistic.  It may return 1 for shreds that did not come from the
      59             :    FEC set whose digest is stored in s.  However, it will never make a
      60             :    mistake in the other direction (returning 0 for a shred that actually
      61             :    did come from the FEC set digested into s).  If
      62             :    fd_fec_samp_set_force_match was called on s, always returns 1.
      63             :    This function is designed to be extremely fast in the common case.
      64             :    About 6% of the time, it will do a sha256 operation, but the rest of
      65             :    the time, it is just O(1) cheap operations.
      66             : 
      67             :    In general, if s does not come from the FEC set digested into s, and
      68             :    it comes from a replayable FEC set, it will return 0 (the correct
      69             :    answer) approximately 99.99% of the time (1-2^-14).
      70             :    There are certainly maliciously constructed FEC sets that are not
      71             :    actually valid that it can detect with much lower probability, only
      72             :    about 3%.
      73             :    Furthermore, extremely rarely (2^-32 of the time), a FEC set will
      74             :    return a degenerate digest, resulting in all shreds returning 1.
      75             :    See below for much more commentary on the probabilities. */
      76             : static inline int
      77             : fd_fec_samp_matches( fd_fec_samp_t const * s,
      78             :                      ulong                 seed,
      79             :                      fd_shred_t const *    shred );
      80             : 
      81             : FD_PROTOTYPES_END
      82             : 
      83             : /* Suppose we derive a sample from FEC set F containing shreds S_0, S_1,
      84             :    ... S_63, where S_i for i in [0, 32) are data shreds.  Let proof(S_i)
      85             :    be the operation that extracts the Merkle proof from shred S_i and
      86             :    contanates the elements into one long bitstring.  If F' is a
      87             :    different FEC set produced honetly, then we expect proof(S_i) and
      88             :    proof(S'_i) to be uncorrelated.  That is, for any set of n bit
      89             :    positions that we extract from both, the probability they will match
      90             :    at all n positions is 2^-n.
      91             : 
      92             :    However, that is not necessarily true when F' is constructed
      93             :    maliciously.  The easiest example of this is to suppose S_0==S'_0,
      94             :    ... S_30=S'_30 but S_31 != S'_31, then proof(S_i) and proof(S'_i)
      95             :    *will* be correlated for i<32.  However, the Reed-Solomon parity
      96             :    shreds will be different, which means proof(S_i) and proof(S'_i) will
      97             :    not be correlated for i>=32.
      98             : 
      99             :    More generally, since Reed-Solomon works on polynomials of degree<32,
     100             :    and modifying a shred can be seen as adding a non-zero polynomial,
     101             :    which then has no more than 31 zeros, if F!=F', then they differ in
     102             :    at least 33 shreds, assuming the erasure coding is computed correctly
     103             :    (otherwise it would be invalid).
     104             : 
     105             :    There is one other attack worth mentioning: the attacker could send
     106             :    shred S'_i but with a Merkle proof cloned from S_i.  Every shred in
     107             :    this sort of attack makes its own FEC set, so this attack can't
     108             :    really be used for real equivocation, but it's a bit lame if we're
     109             :    totally blind to that type of attack because we only consider Merkle
     110             :    proofs.  That means we do sometimes need to hash some shreds.
     111             : 
     112             :    If we didn't have Merkle proofs, we might try an approach like this:
     113             :    using the seed and the (slot, FEC index), choose 32 of the 64 shreds,
     114             :    and a bit position for each.  When you receive a shred, if it's one
     115             :    of the ones in the sample, compute the hash and test if the bit
     116             :    matches.  If it doesn't you know there's equivocation.  Because of
     117             :    our pigeon hole principle/fundamental theorem of algebra argument, we
     118             :    know that if the attacker modified any shred, he modified at least 33
     119             :    of them, which means our sample includes at least one of the modified
     120             :    shreds, so we can detect this equivocation at least with probability
     121             :    0.5.  We'll measure the cost of this as 0.5, since we expect to have
     122             :    to hash half of the shreds.  Since this approach is only shred-based,
     123             :    it's unaffected by the attacker playing games with the Merkle proof.
     124             : 
     125             :    On the other extreme, we could sample 16 bits from each of the
     126             :    highest nodes in the Merkle proof.  Again, assuming the erasure
     127             :    coding is legitimate, F and F' differ in at least 33 shreds, which
     128             :    means both halves of the Merkle tree have at least one differing
     129             :    leaf, so both children of the root will differ.  Since each shred
     130             :    must contain one of the immediate children of the root, the
     131             :    probability we miss an equivocation is 2^-16 (0.0015%).  In this
     132             :    approach, we never have to hash, so we'll say its cost is 0.
     133             :    However, obviously this approach doesn't catch equivocation when the
     134             :    attacker is playing the games with the Merkle proof we considered
     135             :    above or not generating the erasure coding legitimately (recall again
     136             :    that those games prevent this equivocating proof from ever being
     137             :    COMPLETE-able, and thus replayable).
     138             : 
     139             :    Does it ever make sense to take bits from another node?  I think it
     140             :    doesn't.  First of all, as in the case we considered earlier where
     141             :    S_i==S'_i for i<31, it's possible for other nodes in the Merkle tree
     142             :    for F and F' to be equal as long as they have fewer than 32 leaves
     143             :    under them.  Secondly, those bits are only useful for the fraction of
     144             :    shreds that include the node we sampled from.  Thirdly, those bits
     145             :    don't give any additional discriminating power beyond what they would
     146             :    if they were in the root.
     147             : 
     148             :    Then the only question becomes how many bits to allocate to direct
     149             :    hashes, and there's not a mathematical answer, since it's just about
     150             :    our desired sensitivity to the proof-cloning game.  If we store b
     151             :    bits from each hash of h shreds, and we expect to receive each of the
     152             :    64 shreds (which have all been modified but retain their orignal
     153             :    Merkle proofs) with equal probability then we'll detect this as
     154             :    equivocation with probability h/64*(1-2^-b), and this consumes h*b
     155             :    bits total.  This shows for a constant bit-budget, it's better to
     156             :    make h as large as possible and keep b as small as possible.
     157             :    Maximizing h also increases the hashing cost of the approach, so we
     158             :    don't want it too large.
     159             : 
     160             :    All put together, we'll use 14 bits for the left child of the root,
     161             :    14 bits for the right child of the root, and 4 bits for leaf hashes,
     162             :    one bit per hash for each of four shreds. */
     163             : 
     164             : union fd_fec_samp {
     165             :   uint val;
     166             :   struct {
     167             :     ushort data;
     168             :     ushort parity;
     169             :   };
     170             : };
     171             : typedef union fd_fec_samp fd_fec_samp_t;
     172             : 
     173             : 
     174             : /* The following are private helper functions/types */
     175             : typedef struct {
     176             :   ulong mask; /* in [0, 0xFF_FFFF_FFFF] */
     177             :   ulong offset; /* in [0, 15] */
     178             :   ulong shred0; /* in [0, 31] */
     179             :   ulong shred1; /* in [0, 31] */
     180             :   ulong shred_bit; /* in [0, 31], indicating which bit of the selected uint to compare */
     181             :   ulong shred0_off; /* in [0, 4] */
     182             :   ulong shred1_off; /* in [0, 4] */
     183             : } fd_fec_samp_dec_t;
     184             : 
     185             : 
     186             : FD_PROTOTYPES_BEGIN
     187             : 
     188             : static inline void
     189             : fd_fec_samp_decompose( ulong               entropy,
     190           0 :                        fd_fec_samp_dec_t * out ) {
     191           0 :   ulong final_bits = entropy>>59; /* 5 bits left */
     192             : 
     193           0 :   *out = (fd_fec_samp_dec_t) {
     194           0 :     .mask       = (entropy >>  0) & 0xFFFFFFFFFFUL,
     195           0 :     .offset     = (entropy >> 40) & 0xFUL,
     196           0 :     .shred0     = (entropy >> 44) & 0x1FUL,
     197           0 :     .shred1     = (entropy >> 49) & 0x1FUL,
     198           0 :     .shred_bit  = (entropy >> 54) & 0x1FUL,
     199             :       /* We don't have enough entropy left to choose one of 160 bits for
     200             :          each of the two shreds, so we'll just have them use the same
     201             :          bit within a uint and different uint. */
     202           0 :     .shred0_off = final_bits%5UL,
     203           0 :     .shred1_off = (final_bits/5UL)%5UL,
     204           0 :   };
     205             : 
     206             :   /* Fixup to make sure the two shreds are different */
     207           0 :   out->shred1 = fd_ulong_if( out->shred0==out->shred1, (out->shred1)^1UL, out->shred1 );
     208           0 : }
     209             : 
     210             : /* Selects up to 14 bits of 40 bits from input corresponding to 1 bits
     211             :    in mask. */
     212             : static inline ulong
     213             : fd_fec_samp_extract_cor_bits( ulong input,
     214           0 :                               ulong mask ) {
     215             : #if FD_HAS_AVX2 /* Actually BMI2, but the same CPUs support AVX2 as BMI2 */
     216             :   /* It's actually easier to take the first 14 bits with this AND than
     217             :      to make sure the mask only has 14 bits set to 1. */
     218             :   return 0x3FFFUL & _pext_u64( input, mask );
     219             : #else
     220           0 :   ulong cor_bits = 0UL;
     221           0 :   ulong j  = 1UL;
     222           0 :   for( ulong i=0UL; i<40UL; i++ ) if( 1UL & (mask>>i) ) { cor_bits |= fd_ulong_if( 1UL & (input>>i), j, 0UL ); j<<=1; }
     223           0 :   return cor_bits & 0x3FFFUL;
     224           0 : #endif
     225           0 : }
     226             : 
     227             : static inline ushort
     228             : fd_fec_samp_extract( fd_bmtree_commit_t const * bmtree,
     229             :                      fd_fec_samp_dec_t const *  entropy,
     230           0 :                      ulong                      leaf_off ) {
     231           0 :   union {
     232           0 :     uchar p[6][20];
     233           0 :     uchar raw[120 + 8]; /* allow tail reading */
     234           0 :     uint  uints[6][5];
     235           0 :   } proof;
     236             : 
     237             :   /* We can get the hash of shred n by getting the proof of shred n^1 */
     238           0 :   FD_TEST( -1!=fd_bmtree_get_proof( bmtree, proof.raw, leaf_off + (entropy->shred0 ^ 1UL) ) );
     239             : 
     240             :   /* Extract ~14 bits from the child of the root */
     241           0 :   ulong cor_bits = fd_fec_samp_extract_cor_bits( fd_ulong_load_5_fast( proof.p[5] + entropy->offset ), entropy->mask );
     242             : 
     243             :   /* Extract 1 bit from the randomly selected leaf, then 1 bit from the
     244             :      other randomly selected leaf. */
     245           0 :   ulong shred0_bit = 1UL & (proof.uints[0][entropy->shred0_off]>>entropy->shred_bit);
     246             : 
     247           0 :   FD_TEST( -1!=fd_bmtree_get_proof( bmtree, proof.raw, leaf_off + (entropy->shred1 ^ 1UL) ) );
     248           0 :   ulong shred1_bit = 1UL & (proof.uints[0][entropy->shred1_off]>>entropy->shred_bit);
     249             : 
     250           0 :   return (ushort)( (shred0_bit<<15) | (shred1_bit<<14) | cor_bits );
     251           0 : }
     252             : 
     253             : /* end of private helpers */
     254             : 
     255             : static inline void
     256             : fd_fec_samp_derive( fd_fec_samp_t *            s,
     257             :                     ulong                      seed,
     258             :                     ulong                      slot,
     259             :                     uint                       fec_set_idx,
     260             :                     void const *               signature,
     261             :                     fd_bmtree_node_t   const * root,
     262           0 :                     fd_bmtree_commit_t const * bmtree ) {
     263             :   /* We don't use either the signature (since it's not a trustworthy
     264             :      key) or the root (since it's not available in the shred) */
     265           0 :   (void)signature;
     266           0 :   (void)root;
     267             : 
     268             :   /* For the probabilities to be right above, it doesn't matter what
     269             :      bits we choose.  However, an adversary could grind hashes to make
     270             :      it collide if we were using well-known bits, and if all validators
     271             :      were using the same bits, we'd lose the advantage of
     272             :      per-validator statistical independence.  Ideally, we'd choose 14 of
     273             :      the 160 bits uniformly randomly without replacement, probably with
     274             :      something like reservoir sampling.  However, that's a lot of work,
     275             :      and we don't need that level of precision.  Instead we'll generate
     276             :      random 40 bits, and take the first 14 one bits it has (98% chance
     277             :      it will have at least 14 bits set to one), then use that as a mask
     278             :      with 5 contiguous bytes in the 20 byte proof element.  This is not
     279             :      exactly uniform (the last bit has only 0.08% chance of being
     280             :      selected, the first bit has a 3.1% chance of being selected,
     281             :      compared to 8.75% for each bit if we did things perfectly) but it's
     282             :      pretty close; entropy-wise, this is something like picking from 140
     283             :      bits instead of 160 bits. */
     284           0 :   fd_rng_t rng[1];
     285           0 :   fd_rng_join( fd_rng_new( rng, (uint)slot ^ (uint)(fec_set_idx<<20), seed ) );
     286           0 :   fd_fec_samp_dec_t data[1], parity[1];
     287           0 :   fd_fec_samp_decompose( fd_rng_ulong( rng ), data   );
     288           0 :   fd_fec_samp_decompose( fd_rng_ulong( rng ), parity );
     289             : 
     290             :   /* The data shreds are the first 32 shreds in the FEC set/Merkle tree,
     291             :      and the parity shreds are the last 32. */
     292           0 :   s->data   = fd_fec_samp_extract( bmtree, data,    0UL );
     293           0 :   s->parity = fd_fec_samp_extract( bmtree, parity, 32UL );
     294             : 
     295             :   /* It's possible, but extremely unlikely that s->val could be UINT_MAX
     296             :      at this point.  However, an attacker could bias the probabilities
     297             :      in that direction by grinding for hashes with a lot of 1 bits.  We
     298             :      can eliminate that attack vector with another xor */
     299           0 :   s->val ^= (uint)seed;
     300             : 
     301           0 :   fd_rng_delete( fd_rng_leave( rng ) );
     302           0 : }
     303             : 
     304             : static inline int
     305             : fd_fec_samp_matches( fd_fec_samp_t const * s_raw,
     306             :                      ulong                 seed,
     307           0 :                      fd_shred_t const *    shred ) {
     308           0 :   if( FD_UNLIKELY( s_raw->val==UINT_MAX ) ) return 1;
     309             : 
     310           0 :   fd_rng_t rng[1];
     311           0 :   fd_rng_join( fd_rng_new( rng, (uint)shred->slot ^ (uint)(shred->fec_set_idx<<20), seed ) );
     312           0 :   fd_fec_samp_dec_t entropy[1];
     313             :   /* undo the final xor from derive */
     314           0 :   fd_fec_samp_t s[1] = {{ .val = s_raw->val ^ (uint)seed }};
     315             : 
     316           0 :   int is_data_shred = fd_shred_is_data( fd_shred_type( shred->variant ) );
     317           0 :   ushort compare = fd_ushort_if( is_data_shred, s->data, s->parity );
     318             : 
     319           0 :   if( !is_data_shred ) fd_rng_ulong( rng ); /* advance rng */
     320           0 :   fd_fec_samp_decompose( fd_rng_ulong( rng ), entropy );
     321           0 :   fd_rng_delete( fd_rng_leave( rng ) );
     322             : 
     323           0 :   fd_shred_merkle_t const * nodes = fd_shred_merkle_nodes( shred );
     324             : 
     325           0 :   ulong cor_bits = fd_fec_samp_extract_cor_bits( fd_ulong_load_5( nodes[5] + entropy->offset ), entropy->mask );
     326           0 :   if( FD_UNLIKELY( cor_bits!=(compare&0x3FFFUL) ) ) return 0;
     327             : 
     328           0 :   ulong in_type_idx = fd_ulong_if( is_data_shred, shred->idx - shred->fec_set_idx, shred->code.idx );
     329           0 :   if( FD_UNLIKELY( (in_type_idx==entropy->shred0) | (in_type_idx==entropy->shred1) ) ) {
     330             :     /* This is one of the shreds we need to hash. */
     331           0 :     ulong merkle_protected_sz  = fd_ulong_if( is_data_shred, FD_SHRED_MIN_SZ, FD_SHRED_MAX_SZ )
     332           0 :                                   - FD_SHRED_SIGNATURE_SZ * (1UL+fd_shred_is_resigned( fd_shred_type( shred->variant ) ))
     333           0 :                                   - FD_SHRED_MERKLE_NODE_SZ*(FD_SHRED_MERKLE_LAYER_CNT-1UL);
     334             : 
     335           0 :     union {
     336           0 :       fd_bmtree_node_t leaf[1];
     337           0 :       uint             u[5];
     338           0 :     } l;
     339             : 
     340           0 :     fd_bmtree_hash_leaf( l.leaf, (uchar const *)shred + sizeof(fd_ed25519_sig_t), merkle_protected_sz, FD_BMTREE_LONG_PREFIX_SZ );
     341           0 :     ulong bit = 1UL & (l.u[ fd_ulong_if( in_type_idx==entropy->shred0, entropy->shred0_off, entropy->shred1_off ) ]>>entropy->shred_bit);
     342           0 :     if( FD_UNLIKELY( bit!=(1UL & fd_ulong_if( in_type_idx==entropy->shred0, compare>>15, compare>>14 ) ) ) ) return 0;
     343           0 :   }
     344             : 
     345           0 :   return 1;
     346           0 : }
     347             : 
     348        4572 : static inline void fd_fec_samp_set_force_match( fd_fec_samp_t       * s ) { s->val        =  UINT_MAX; }
     349             : 
     350             : FD_PROTOTYPES_END
     351             : 
     352             : #endif /* HEADER_fd_src_disco_shred_fd_fec_samp_h */

Generated by: LCOV version 1.14