package light_test import ( "bytes" "context" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" dbm "github.com/tendermint/tm-db" "github.com/tendermint/tendermint/libs/log" "github.com/tendermint/tendermint/light" "github.com/tendermint/tendermint/light/provider" provider_mocks "github.com/tendermint/tendermint/light/provider/mocks" dbs "github.com/tendermint/tendermint/light/store/db" "github.com/tendermint/tendermint/types" ) func TestLightClientAttackEvidence_Lunatic(t *testing.T) { logger := log.NewTestingLogger(t) // primary performs a lunatic attack var ( latestHeight = int64(3) valSize = 5 divergenceHeight = int64(2) primaryHeaders = make(map[int64]*types.SignedHeader, latestHeight) primaryValidators = make(map[int64]*types.ValidatorSet, latestHeight) ) ctx, cancel := context.WithCancel(context.Background()) defer cancel() witnessHeaders, witnessValidators, chainKeys := genLightBlocksWithKeys(chainID, latestHeight, valSize, 2, bTime) forgedKeys := chainKeys[divergenceHeight-1].ChangeKeys(3) // we change 3 out of the 5 validators (still 2/5 remain) forgedVals := forgedKeys.ToValidators(2, 0) for height := int64(1); height <= latestHeight; height++ { if height < divergenceHeight { primaryHeaders[height] = witnessHeaders[height] primaryValidators[height] = witnessValidators[height] continue } primaryHeaders[height] = forgedKeys.GenSignedHeader(chainID, height, bTime.Add(time.Duration(height)*time.Minute), nil, forgedVals, forgedVals, hash("app_hash"), hash("cons_hash"), hash("results_hash"), 0, len(forgedKeys)) primaryValidators[height] = forgedVals } // never called, delete it to make mockery asserts pass delete(witnessHeaders, 2) delete(primaryHeaders, 2) mockWitness := mockNodeFromHeadersAndVals(witnessHeaders, witnessValidators) mockPrimary := mockNodeFromHeadersAndVals(primaryHeaders, primaryValidators) mockWitness.On("ReportEvidence", mock.Anything, mock.MatchedBy(func(evidence types.Evidence) bool { evAgainstPrimary := &types.LightClientAttackEvidence{ // after the divergence height the valset doesn't change so we expect the evidence to be for the latest height ConflictingBlock: &types.LightBlock{ SignedHeader: primaryHeaders[latestHeight], ValidatorSet: primaryValidators[latestHeight], }, CommonHeight: 1, } return bytes.Equal(evidence.Hash(), evAgainstPrimary.Hash()) })).Return(nil) mockPrimary.On("ReportEvidence", mock.Anything, mock.MatchedBy(func(evidence types.Evidence) bool { evAgainstWitness := &types.LightClientAttackEvidence{ // when forming evidence against witness we learn that the canonical chain continued to change validator sets // hence the conflicting block is at 7 ConflictingBlock: &types.LightBlock{ SignedHeader: witnessHeaders[divergenceHeight+1], ValidatorSet: witnessValidators[divergenceHeight+1], }, CommonHeight: divergenceHeight - 1, } return bytes.Equal(evidence.Hash(), evAgainstWitness.Hash()) })).Return(nil) c, err := light.NewClient( ctx, chainID, light.TrustOptions{ Period: 4 * time.Hour, Height: 1, Hash: primaryHeaders[1].Hash(), }, mockPrimary, []provider.Provider{mockWitness}, dbs.New(dbm.NewMemDB()), light.Logger(logger), ) require.NoError(t, err) // Check verification returns an error. _, err = c.VerifyLightBlockAtHeight(ctx, latestHeight, bTime.Add(1*time.Hour)) if assert.Error(t, err) { assert.Equal(t, light.ErrLightClientAttack, err) } mockWitness.AssertExpectations(t) mockPrimary.AssertExpectations(t) } func TestLightClientAttackEvidence_Equivocation(t *testing.T) { cases := []struct { name string lightOption light.Option unusedWitnessBlockHeights []int64 unusedPrimaryBlockHeights []int64 latestHeight int64 divergenceHeight int64 }{ { name: "sequential", lightOption: light.SequentialVerification(), unusedWitnessBlockHeights: []int64{4, 6}, latestHeight: int64(5), divergenceHeight: int64(3), }, { name: "skipping", lightOption: light.SkippingVerification(light.DefaultTrustLevel), unusedWitnessBlockHeights: []int64{2, 4, 6}, unusedPrimaryBlockHeights: []int64{2, 4, 6}, latestHeight: int64(5), divergenceHeight: int64(3), }, } bctx, bcancel := context.WithCancel(context.Background()) defer bcancel() for _, tc := range cases { testCase := tc t.Run(testCase.name, func(t *testing.T) { ctx, cancel := context.WithCancel(bctx) defer cancel() logger := log.NewTestingLogger(t) // primary performs an equivocation attack var ( valSize = 5 primaryHeaders = make(map[int64]*types.SignedHeader, testCase.latestHeight) // validators don't change in this network (however we still use a map just for convenience) primaryValidators = make(map[int64]*types.ValidatorSet, testCase.latestHeight) ) witnessHeaders, witnessValidators, chainKeys := genLightBlocksWithKeys(chainID, testCase.latestHeight+1, valSize, 2, bTime) for height := int64(1); height <= testCase.latestHeight; height++ { if height < testCase.divergenceHeight { primaryHeaders[height] = witnessHeaders[height] primaryValidators[height] = witnessValidators[height] continue } // we don't have a network partition so we will make 4/5 (greater than 2/3) malicious and vote again for // a different block (which we do by adding txs) primaryHeaders[height] = chainKeys[height].GenSignedHeader(chainID, height, bTime.Add(time.Duration(height)*time.Minute), []types.Tx{[]byte("abcd")}, witnessValidators[height], witnessValidators[height+1], hash("app_hash"), hash("cons_hash"), hash("results_hash"), 0, len(chainKeys[height])-1) primaryValidators[height] = witnessValidators[height] } for _, height := range testCase.unusedWitnessBlockHeights { delete(witnessHeaders, height) } mockWitness := mockNodeFromHeadersAndVals(witnessHeaders, witnessValidators) for _, height := range testCase.unusedPrimaryBlockHeights { delete(primaryHeaders, height) } mockPrimary := mockNodeFromHeadersAndVals(primaryHeaders, primaryValidators) // Check evidence was sent to both full nodes. // Common height should be set to the height of the divergent header in the instance // of an equivocation attack and the validator sets are the same as what the witness has mockWitness.On("ReportEvidence", mock.Anything, mock.MatchedBy(func(evidence types.Evidence) bool { evAgainstPrimary := &types.LightClientAttackEvidence{ ConflictingBlock: &types.LightBlock{ SignedHeader: primaryHeaders[testCase.divergenceHeight], ValidatorSet: primaryValidators[testCase.divergenceHeight], }, CommonHeight: testCase.divergenceHeight, } return bytes.Equal(evidence.Hash(), evAgainstPrimary.Hash()) })).Return(nil) mockPrimary.On("ReportEvidence", mock.Anything, mock.MatchedBy(func(evidence types.Evidence) bool { evAgainstWitness := &types.LightClientAttackEvidence{ ConflictingBlock: &types.LightBlock{ SignedHeader: witnessHeaders[testCase.divergenceHeight], ValidatorSet: witnessValidators[testCase.divergenceHeight], }, CommonHeight: testCase.divergenceHeight, } return bytes.Equal(evidence.Hash(), evAgainstWitness.Hash()) })).Return(nil) c, err := light.NewClient( ctx, chainID, light.TrustOptions{ Period: 4 * time.Hour, Height: 1, Hash: primaryHeaders[1].Hash(), }, mockPrimary, []provider.Provider{mockWitness}, dbs.New(dbm.NewMemDB()), light.Logger(logger), testCase.lightOption, ) require.NoError(t, err) // Check verification returns an error. _, err = c.VerifyLightBlockAtHeight(ctx, testCase.latestHeight, bTime.Add(300*time.Second)) if assert.Error(t, err) { assert.Equal(t, light.ErrLightClientAttack, err) } mockWitness.AssertExpectations(t) mockPrimary.AssertExpectations(t) }) } } func TestLightClientAttackEvidence_ForwardLunatic(t *testing.T) { // primary performs a lunatic attack but changes the time of the header to // something in the future relative to the blockchain var ( latestHeight = int64(10) valSize = 5 forgedHeight = int64(12) proofHeight = int64(11) primaryHeaders = make(map[int64]*types.SignedHeader, forgedHeight) primaryValidators = make(map[int64]*types.ValidatorSet, forgedHeight) ) ctx, cancel := context.WithCancel(context.Background()) defer cancel() logger := log.NewTestingLogger(t) witnessHeaders, witnessValidators, chainKeys := genLightBlocksWithKeys(chainID, latestHeight, valSize, 2, bTime) for _, unusedHeader := range []int64{3, 5, 6, 8} { delete(witnessHeaders, unusedHeader) } // primary has the exact same headers except it forges one extra header in the future using keys from 2/5ths of // the validators for h := range witnessHeaders { primaryHeaders[h] = witnessHeaders[h] primaryValidators[h] = witnessValidators[h] } for _, unusedHeader := range []int64{3, 5, 6, 8} { delete(primaryHeaders, unusedHeader) } forgedKeys := chainKeys[latestHeight].ChangeKeys(3) // we change 3 out of the 5 validators (still 2/5 remain) primaryValidators[forgedHeight] = forgedKeys.ToValidators(2, 0) primaryHeaders[forgedHeight] = forgedKeys.GenSignedHeader( chainID, forgedHeight, bTime.Add(time.Duration(latestHeight+1)*time.Minute), // 11 mins nil, primaryValidators[forgedHeight], primaryValidators[forgedHeight], hash("app_hash"), hash("cons_hash"), hash("results_hash"), 0, len(forgedKeys), ) mockPrimary := mockNodeFromHeadersAndVals(primaryHeaders, primaryValidators) lastBlock, _ := mockPrimary.LightBlock(ctx, forgedHeight) mockPrimary.On("LightBlock", mock.Anything, int64(0)).Return(lastBlock, nil) mockPrimary.On("LightBlock", mock.Anything, mock.Anything).Return(nil, provider.ErrLightBlockNotFound) /* for _, unusedHeader := range []int64{3, 5, 6, 8} { delete(witnessHeaders, unusedHeader) } */ mockWitness := mockNodeFromHeadersAndVals(witnessHeaders, witnessValidators) lastBlock, _ = mockWitness.LightBlock(ctx, latestHeight) mockWitness.On("LightBlock", mock.Anything, int64(0)).Return(lastBlock, nil).Once() mockWitness.On("LightBlock", mock.Anything, int64(12)).Return(nil, provider.ErrHeightTooHigh) mockWitness.On("ReportEvidence", mock.Anything, mock.MatchedBy(func(evidence types.Evidence) bool { // Check evidence was sent to the witness against the full node evAgainstPrimary := &types.LightClientAttackEvidence{ ConflictingBlock: &types.LightBlock{ SignedHeader: primaryHeaders[forgedHeight], ValidatorSet: primaryValidators[forgedHeight], }, CommonHeight: latestHeight, } return bytes.Equal(evidence.Hash(), evAgainstPrimary.Hash()) })).Return(nil).Twice() // In order to perform the attack, the primary needs at least one accomplice as a witness to also // send the forged block accomplice := mockPrimary c, err := light.NewClient( ctx, chainID, light.TrustOptions{ Period: 4 * time.Hour, Height: 1, Hash: primaryHeaders[1].Hash(), }, mockPrimary, []provider.Provider{mockWitness, accomplice}, dbs.New(dbm.NewMemDB()), light.Logger(logger), light.MaxClockDrift(1*time.Second), light.MaxBlockLag(1*time.Second), ) require.NoError(t, err) // two seconds later, the supporting withness should receive the header that can be used // to prove that there was an attack vals := chainKeys[latestHeight].ToValidators(2, 0) newLb := &types.LightBlock{ SignedHeader: chainKeys[latestHeight].GenSignedHeader( chainID, proofHeight, bTime.Add(time.Duration(proofHeight+1)*time.Minute), // 12 mins nil, vals, vals, hash("app_hash"), hash("cons_hash"), hash("results_hash"), 0, len(chainKeys), ), ValidatorSet: vals, } go func() { time.Sleep(2 * time.Second) mockWitness.On("LightBlock", mock.Anything, int64(0)).Return(newLb, nil) }() // Now assert that verification returns an error. We craft the light clients time to be a little ahead of the chain // to allow a window for the attack to manifest itself. _, err = c.Update(ctx, bTime.Add(time.Duration(forgedHeight)*time.Minute)) if assert.Error(t, err) { assert.Equal(t, light.ErrLightClientAttack, err) } // We attempt the same call but now the supporting witness has a block which should // immediately conflict in time with the primary _, err = c.VerifyLightBlockAtHeight(ctx, forgedHeight, bTime.Add(time.Duration(forgedHeight)*time.Minute)) if assert.Error(t, err) { assert.Equal(t, light.ErrLightClientAttack, err) } // Lastly we test the unfortunate case where the light clients supporting witness doesn't update // in enough time mockLaggingWitness := mockNodeFromHeadersAndVals(witnessHeaders, witnessValidators) mockLaggingWitness.On("LightBlock", mock.Anything, int64(12)).Return(nil, provider.ErrHeightTooHigh) lastBlock, _ = mockLaggingWitness.LightBlock(ctx, latestHeight) mockLaggingWitness.On("LightBlock", mock.Anything, int64(0)).Return(lastBlock, nil) c, err = light.NewClient( ctx, chainID, light.TrustOptions{ Period: 4 * time.Hour, Height: 1, Hash: primaryHeaders[1].Hash(), }, mockPrimary, []provider.Provider{mockLaggingWitness, accomplice}, dbs.New(dbm.NewMemDB()), light.Logger(logger), light.MaxClockDrift(1*time.Second), light.MaxBlockLag(1*time.Second), ) require.NoError(t, err) _, err = c.Update(ctx, bTime.Add(time.Duration(forgedHeight)*time.Minute)) assert.NoError(t, err) mockPrimary.AssertExpectations(t) mockWitness.AssertExpectations(t) } // 1. Different nodes therefore a divergent header is produced. // => light client returns an error upon creation because primary and witness // have a different view. func TestClientDivergentTraces1(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() headers, vals, _ := genLightBlocksWithKeys(chainID, 1, 5, 2, bTime) mockPrimary := mockNodeFromHeadersAndVals(headers, vals) firstBlock, err := mockPrimary.LightBlock(ctx, 1) require.NoError(t, err) headers, vals, _ = genLightBlocksWithKeys(chainID, 1, 5, 2, bTime) mockWitness := mockNodeFromHeadersAndVals(headers, vals) logger := log.NewTestingLogger(t) _, err = light.NewClient( ctx, chainID, light.TrustOptions{ Height: 1, Hash: firstBlock.Hash(), Period: 4 * time.Hour, }, mockPrimary, []provider.Provider{mockWitness}, dbs.New(dbm.NewMemDB()), light.Logger(logger), ) require.Error(t, err) assert.Contains(t, err.Error(), "does not match primary") mockWitness.AssertExpectations(t) mockPrimary.AssertExpectations(t) } // 2. Two out of three nodes don't respond but the third has a header that matches // => verification should be successful and all the witnesses should remain func TestClientDivergentTraces2(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() logger := log.NewTestingLogger(t) headers, vals, _ := genLightBlocksWithKeys(chainID, 2, 5, 2, bTime) mockPrimaryNode := mockNodeFromHeadersAndVals(headers, vals) mockDeadNode := &provider_mocks.Provider{} mockDeadNode.On("LightBlock", mock.Anything, mock.Anything).Return(nil, provider.ErrNoResponse) firstBlock, err := mockPrimaryNode.LightBlock(ctx, 1) require.NoError(t, err) c, err := light.NewClient( ctx, chainID, light.TrustOptions{ Height: 1, Hash: firstBlock.Hash(), Period: 4 * time.Hour, }, mockPrimaryNode, []provider.Provider{mockDeadNode, mockDeadNode, mockPrimaryNode}, dbs.New(dbm.NewMemDB()), light.Logger(logger), ) require.NoError(t, err) _, err = c.VerifyLightBlockAtHeight(ctx, 2, bTime.Add(1*time.Hour)) assert.NoError(t, err) assert.Equal(t, 3, len(c.Witnesses())) mockDeadNode.AssertExpectations(t) mockPrimaryNode.AssertExpectations(t) } // 3. witness has the same first header, but different second header // => creation should succeed, but the verification should fail //nolint: dupl func TestClientDivergentTraces3(t *testing.T) { logger := log.NewTestingLogger(t) // primaryHeaders, primaryVals, _ := genLightBlocksWithKeys(chainID, 2, 5, 2, bTime) mockPrimary := mockNodeFromHeadersAndVals(primaryHeaders, primaryVals) ctx, cancel := context.WithCancel(context.Background()) defer cancel() firstBlock, err := mockPrimary.LightBlock(ctx, 1) require.NoError(t, err) mockHeaders, mockVals, _ := genLightBlocksWithKeys(chainID, 2, 5, 2, bTime) mockHeaders[1] = primaryHeaders[1] mockVals[1] = primaryVals[1] mockWitness := mockNodeFromHeadersAndVals(mockHeaders, mockVals) c, err := light.NewClient( ctx, chainID, light.TrustOptions{ Height: 1, Hash: firstBlock.Hash(), Period: 4 * time.Hour, }, mockPrimary, []provider.Provider{mockWitness}, dbs.New(dbm.NewMemDB()), light.Logger(logger), ) require.NoError(t, err) _, err = c.VerifyLightBlockAtHeight(ctx, 2, bTime.Add(1*time.Hour)) assert.Error(t, err) assert.Equal(t, 1, len(c.Witnesses())) mockWitness.AssertExpectations(t) mockPrimary.AssertExpectations(t) } // 4. Witness has a divergent header but can not produce a valid trace to back it up. // It should be ignored //nolint: dupl func TestClientDivergentTraces4(t *testing.T) { logger := log.NewTestingLogger(t) // primaryHeaders, primaryVals, _ := genLightBlocksWithKeys(chainID, 2, 5, 2, bTime) mockPrimary := mockNodeFromHeadersAndVals(primaryHeaders, primaryVals) ctx, cancel := context.WithCancel(context.Background()) defer cancel() firstBlock, err := mockPrimary.LightBlock(ctx, 1) require.NoError(t, err) witnessHeaders, witnessVals, _ := genLightBlocksWithKeys(chainID, 2, 5, 2, bTime) primaryHeaders[2] = witnessHeaders[2] primaryVals[2] = witnessVals[2] mockWitness := mockNodeFromHeadersAndVals(primaryHeaders, primaryVals) c, err := light.NewClient( ctx, chainID, light.TrustOptions{ Height: 1, Hash: firstBlock.Hash(), Period: 4 * time.Hour, }, mockPrimary, []provider.Provider{mockWitness}, dbs.New(dbm.NewMemDB()), light.Logger(logger), ) require.NoError(t, err) _, err = c.VerifyLightBlockAtHeight(ctx, 2, bTime.Add(1*time.Hour)) assert.Error(t, err) assert.Equal(t, 1, len(c.Witnesses())) mockWitness.AssertExpectations(t) mockPrimary.AssertExpectations(t) }