package light import ( "bytes" "context" "errors" "fmt" "time" "github.com/tendermint/tendermint/light/provider" "github.com/tendermint/tendermint/types" ) // The detector component of the light client detects and handles attacks on the light client. // More info here: // tendermint/docs/architecture/adr-047-handling-evidence-from-light-client.md // detectDivergence is a second wall of defense for the light client. // // It takes the target verified header and compares it with the headers of a set of // witness providers that the light client is connected to. If a conflicting header // is returned it verifies and examines the conflicting header against the verified // trace that was produced from the primary. If successful, it produces two sets of evidence // and sends them to the opposite provider before halting. // // If there are no conflictinge headers, the light client deems the verified target header // trusted and saves it to the trusted store. func (c *Client) detectDivergence(ctx context.Context, primaryTrace []*types.LightBlock, now time.Time) error { if len(c.witnesses) < 1 { return nil } if primaryTrace == nil || len(primaryTrace) < 2 { return errors.New("nil or single block primary trace") } var ( headerMatched bool lastVerifiedHeader = primaryTrace[len(primaryTrace)-1].SignedHeader witnessesToRemove = make([]int, 0) ) c.logger.Debug("running detector against trace", "endBlockHeight", lastVerifiedHeader.Height, "endBlockHash", lastVerifiedHeader.Hash, "length", len(primaryTrace)) c.providerMutex.Lock() defer c.providerMutex.Unlock() // launch one goroutine per witness to retrieve the light block of the target height // and compare it with the header from the primary errc := make(chan error, len(c.witnesses)) for i, witness := range c.witnesses { go c.compareNewHeaderWithWitness(ctx, errc, lastVerifiedHeader, witness, i) } // handle errors from the header comparisons as they come in for i := 0; i < cap(errc); i++ { err := <-errc switch e := err.(type) { case nil: // at least one header matched headerMatched = true case errConflictingHeaders: // We have conflicting headers. This could possibly imply an attack on the light client. // First we need to verify the witness's header using the same skipping verification and then we // need to find the point that the headers diverge and examine this for any evidence of an attack. // // We combine these actions together, verifying the witnesses headers and outputting the trace // which captures the bifurcation point and if successful provides the information to create valid evidence. err := c.handleConflictingHeaders(ctx, primaryTrace, e.Block, e.WitnessIndex, now) if err != nil { // return information of the attack return err } // if attempt to generate conflicting headers failed then remove witness witnessesToRemove = append(witnessesToRemove, e.WitnessIndex) case errBadWitness: c.logger.Info("witness returned an error during header comparison, removing...", "witness", c.witnesses[e.WitnessIndex], "err", err) witnessesToRemove = append(witnessesToRemove, e.WitnessIndex) default: if errors.Is(e, context.Canceled) || errors.Is(e, context.DeadlineExceeded) { return e } c.logger.Info("error in light block request to witness", "err", err) } } // remove witnesses that have misbehaved if err := c.removeWitnesses(witnessesToRemove); err != nil { return err } // 1. If we had at least one witness that returned the same header then we // conclude that we can trust the header if headerMatched { return nil } // 2. Else all witnesses have either not responded, don't have the block or sent invalid blocks. return ErrFailedHeaderCrossReferencing } // compareNewHeaderWithWitness takes the verified header from the primary and compares it with a // header from a specified witness. The function can return one of three errors: // // 1: errConflictingHeaders -> there may have been an attack on this light client // 2: errBadWitness -> the witness has either not responded, doesn't have the header or has given us an invalid one // Note: In the case of an invalid header we remove the witness // 3: nil -> the hashes of the two headers match func (c *Client) compareNewHeaderWithWitness(ctx context.Context, errc chan error, h *types.SignedHeader, witness provider.Provider, witnessIndex int) { lightBlock, err := c.getLightBlock(ctx, witness, h.Height) switch err { // no error means we move on to checking the hash of the two headers case nil: break // the witness hasn't been helpful in comparing headers, we mark the response and continue // comparing with the rest of the witnesses case provider.ErrNoResponse, provider.ErrLightBlockNotFound, context.DeadlineExceeded, context.Canceled: errc <- err return // the witness' head of the blockchain is lower than the height of the primary. This could be one of // two things: // 1) The witness is lagging behind // 2) The primary may be performing a lunatic attack with a height and time in the future case provider.ErrHeightTooHigh: // The light client now asks for the latest header that the witness has var isTargetHeight bool isTargetHeight, lightBlock, err = c.getTargetBlockOrLatest(ctx, h.Height, witness) if err != nil { if c.providerShouldBeRemoved(err) { errc <- errBadWitness{Reason: err, WitnessIndex: witnessIndex} } else { errc <- err } return } // if the witness caught up and has returned a block of the target height then we can // break from this switch case and continue to verify the hashes if isTargetHeight { break } // witness' last header is below the primary's header. We check the times to see if the blocks // have conflicting times if !lightBlock.Time.Before(h.Time) { errc <- errConflictingHeaders{Block: lightBlock, WitnessIndex: witnessIndex} return } // the witness is behind. We wait for a period WAITING = 2 * DRIFT + LAG. // This should give the witness ample time if it is a participating member // of consensus to produce a block that has a time that is after the primary's // block time. If not the witness is too far behind and the light client removes it time.Sleep(2*c.maxClockDrift + c.maxBlockLag) isTargetHeight, lightBlock, err = c.getTargetBlockOrLatest(ctx, h.Height, witness) if err != nil { if c.providerShouldBeRemoved(err) { errc <- errBadWitness{Reason: err, WitnessIndex: witnessIndex} } else { errc <- err } return } if isTargetHeight { break } // the witness still doesn't have a block at the height of the primary. // Check if there is a conflicting time if !lightBlock.Time.Before(h.Time) { errc <- errConflictingHeaders{Block: lightBlock, WitnessIndex: witnessIndex} return } // Following this request response procedure, the witness has been unable to produce a block // that can somehow conflict with the primary's block. We thus conclude that the witness // is too far behind and thus we return a no response error. // // NOTE: If the clock drift / lag has been miscalibrated it is feasible that the light client has // drifted too far ahead for any witness to be able provide a comparable block and thus may allow // for a malicious primary to attack it errc <- provider.ErrNoResponse return default: // all other errors (i.e. invalid block, closed connection or unreliable provider) we mark the // witness as bad and remove it errc <- errBadWitness{Reason: err, WitnessIndex: witnessIndex} return } if !bytes.Equal(h.Header.Hash(), lightBlock.Header.Hash()) { errc <- errConflictingHeaders{Block: lightBlock, WitnessIndex: witnessIndex} } c.logger.Debug("matching header received by witness", "height", h.Height, "witness", witnessIndex) errc <- nil } // sendEvidence sends evidence to a provider on a best effort basis. func (c *Client) sendEvidence(ctx context.Context, ev *types.LightClientAttackEvidence, receiver provider.Provider) { err := receiver.ReportEvidence(ctx, ev) if err != nil { c.logger.Error("failed to report evidence to provider", "ev", ev, "provider", receiver) } } // handleConflictingHeaders handles the primary style of attack, which is where a primary and witness have // two headers of the same height but with different hashes func (c *Client) handleConflictingHeaders( ctx context.Context, primaryTrace []*types.LightBlock, challendingBlock *types.LightBlock, witnessIndex int, now time.Time, ) error { supportingWitness := c.witnesses[witnessIndex] witnessTrace, primaryBlock, err := c.examineConflictingHeaderAgainstTrace( ctx, primaryTrace, challendingBlock, supportingWitness, now, ) if err != nil { c.logger.Info("error validating witness's divergent header", "witness", supportingWitness, "err", err) return nil } // We are suspecting that the primary is faulty, hence we hold the witness as the source of truth // and generate evidence against the primary that we can send to the witness commonBlock, trustedBlock := witnessTrace[0], witnessTrace[len(witnessTrace)-1] evidenceAgainstPrimary := newLightClientAttackEvidence(primaryBlock, trustedBlock, commonBlock) c.logger.Error("ATTEMPTED ATTACK DETECTED. Sending evidence againt primary by witness", "ev", evidenceAgainstPrimary, "primary", c.primary, "witness", supportingWitness) c.sendEvidence(ctx, evidenceAgainstPrimary, supportingWitness) if primaryBlock.Commit.Round != witnessTrace[len(witnessTrace)-1].Commit.Round { c.logger.Info("The light client has detected, and prevented, an attempted amnesia attack." + " We think this attack is pretty unlikely, so if you see it, that's interesting to us." + " Can you let us know by opening an issue through https://github.com/tendermint/tendermint/issues/new?") } // This may not be valid because the witness itself is at fault. So now we reverse it, examining the // trace provided by the witness and holding the primary as the source of truth. Note: primary may not // respond but this is okay as we will halt anyway. primaryTrace, witnessBlock, err := c.examineConflictingHeaderAgainstTrace( ctx, witnessTrace, primaryBlock, c.primary, now, ) if err != nil { c.logger.Info("error validating primary's divergent header", "primary", c.primary, "err", err) return ErrLightClientAttack } // We now use the primary trace to create evidence against the witness and send it to the primary commonBlock, trustedBlock = primaryTrace[0], primaryTrace[len(primaryTrace)-1] evidenceAgainstWitness := newLightClientAttackEvidence(witnessBlock, trustedBlock, commonBlock) c.logger.Error("Sending evidence against witness by primary", "ev", evidenceAgainstWitness, "primary", c.primary, "witness", supportingWitness) c.sendEvidence(ctx, evidenceAgainstWitness, c.primary) // We return the error and don't process anymore witnesses return ErrLightClientAttack } // examineConflictingHeaderAgainstTrace takes a trace from one provider and a divergent header that // it has received from another and preforms verifySkipping at the heights of each of the intermediate // headers in the trace until it reaches the divergentHeader. 1 of 2 things can happen. // // 1. The light client verifies a header that is different to the intermediate header in the trace. This // is the bifurcation point and the light client can create evidence from it // 2. The source stops responding, doesn't have the block or sends an invalid header in which case we // return the error and remove the witness // // CONTRACT: // 1. Trace can not be empty len(trace) > 0 // 2. The last block in the trace can not be of a lower height than the target block // trace[len(trace)-1].Height >= targetBlock.Height // 3. The func (c *Client) examineConflictingHeaderAgainstTrace( ctx context.Context, trace []*types.LightBlock, targetBlock *types.LightBlock, source provider.Provider, now time.Time, ) ([]*types.LightBlock, *types.LightBlock, error) { var ( previouslyVerifiedBlock, sourceBlock *types.LightBlock sourceTrace []*types.LightBlock err error ) if targetBlock.Height < trace[0].Height { return nil, nil, fmt.Errorf("target block has a height lower than the trusted height (%d < %d)", targetBlock.Height, trace[0].Height) } for idx, traceBlock := range trace { // this case only happens in a forward lunatic attack. We treat the block with the // height directly after the targetBlock as the divergent block if traceBlock.Height > targetBlock.Height { // sanity check that the time of the traceBlock is indeed less than that of the targetBlock. If the trace // was correctly verified we should expect monotonically increasing time. This means that if the block at // the end of the trace has a lesser time than the target block then all blocks in the trace should have a // lesser time if traceBlock.Time.After(targetBlock.Time) { return nil, nil, errors.New("sanity check failed: expected traceblock to have a lesser time than the target block") } // before sending back the divergent block and trace we need to ensure we have verified // the final gap between the previouslyVerifiedBlock and the targetBlock if previouslyVerifiedBlock.Height != targetBlock.Height { sourceTrace, err = c.verifySkipping(ctx, source, previouslyVerifiedBlock, targetBlock, now) if err != nil { return nil, nil, fmt.Errorf("verifySkipping of conflicting header failed: %w", err) } } return sourceTrace, traceBlock, nil } // get the corresponding block from the source to verify and match up against the traceBlock if traceBlock.Height == targetBlock.Height { sourceBlock = targetBlock } else { sourceBlock, err = c.getLightBlock(ctx, source, traceBlock.Height) if err != nil { return nil, nil, fmt.Errorf("failed to examine trace: %w", err) } } // The first block in the trace MUST be the same to the light block that the source produces // else we cannot continue with verification. if idx == 0 { if shash, thash := sourceBlock.Hash(), traceBlock.Hash(); !bytes.Equal(shash, thash) { return nil, nil, fmt.Errorf("trusted block is different to the source's first block (%X = %X)", thash, shash) } previouslyVerifiedBlock = sourceBlock continue } // we check that the source provider can verify a block at the same height of the // intermediate height sourceTrace, err = c.verifySkipping(ctx, source, previouslyVerifiedBlock, sourceBlock, now) if err != nil { return nil, nil, fmt.Errorf("verifySkipping of conflicting header failed: %w", err) } // check if the headers verified by the source has diverged from the trace if shash, thash := sourceBlock.Hash(), traceBlock.Hash(); !bytes.Equal(shash, thash) { // Bifurcation point found! return sourceTrace, traceBlock, nil } // headers are still the same. update the previouslyVerifiedBlock previouslyVerifiedBlock = sourceBlock } // We have reached the end of the trace. This should never happen. This can only happen if one of the stated // prerequisites to this function were not met. Namely that either trace[len(trace)-1].Height < targetBlock.Height // or that trace[i].Hash() != targetBlock.Hash() return nil, nil, errNoDivergence } // getTargetBlockOrLatest gets the latest height, if it is greater than the target height then it queries // the target heght else it returns the latest. returns true if it successfully managed to acquire the target // height. func (c *Client) getTargetBlockOrLatest( ctx context.Context, height int64, witness provider.Provider, ) (bool, *types.LightBlock, error) { lightBlock, err := c.getLightBlock(ctx, witness, 0) if err != nil { return false, nil, err } if lightBlock.Height == height { // the witness has caught up to the height of the provider's signed header. We // can resume with checking the hashes. return true, lightBlock, nil } if lightBlock.Height > height { // the witness has caught up. We recursively call the function again. However in order // to avoud a wild goose chase where the witness sends us one header below and one header // above the height we set a timeout to the context lightBlock, err := c.getLightBlock(ctx, witness, height) return true, lightBlock, err } return false, lightBlock, nil } // newLightClientAttackEvidence determines the type of attack and then forms the evidence filling out // all the fields such that it is ready to be sent to a full node. func newLightClientAttackEvidence(conflicted, trusted, common *types.LightBlock) *types.LightClientAttackEvidence { ev := &types.LightClientAttackEvidence{ConflictingBlock: conflicted} // We use the common height to indicate the form of the attack. // if this is an equivocation or amnesia attack, i.e. the validator sets are the same, then we // return the height of the conflicting block as the common height. If instead it is a lunatic // attack and the validator sets are not the same then we send the height of the common header. if ev.ConflictingHeaderIsInvalid(trusted.Header) { ev.CommonHeight = common.Height ev.Timestamp = common.Time ev.TotalVotingPower = common.ValidatorSet.TotalVotingPower() } else { ev.CommonHeight = trusted.Height ev.Timestamp = trusted.Time ev.TotalVotingPower = trusted.ValidatorSet.TotalVotingPower() } ev.ByzantineValidators = ev.GetByzantineValidators(common.ValidatorSet, trusted.SignedHeader) return ev }