diff --git a/framework/src/node/consensus/consensus.ts b/framework/src/node/consensus/consensus.ts index f0726a116f0..28e50dd85f4 100644 --- a/framework/src/node/consensus/consensus.ts +++ b/framework/src/node/consensus/consensus.ts @@ -45,7 +45,7 @@ import { } from './constants'; import { GenesisConfig } from '../../types'; import { ValidatorAPI, BFTAPI } from './types'; -import { createAPIContext } from '../state_machine'; +import { APIContext, createAPIContext } from '../state_machine'; import { forkChoice, ForkStatus } from './fork_choice/fork_choice_rule'; import { createNewAPIContext } from '../state_machine/api_context'; @@ -465,10 +465,142 @@ export class Consensus { if (block.header.version !== BLOCK_VERSION) { throw new ApplyPenaltyError(`Block version must be ${BLOCK_VERSION}`); } + const apiContext = createNewAPIContext(this._db); + + // Verify timestamp + await this._verifyTimestamp(apiContext, block); + + // Verify height + this._verifyBlockHeight(block); + + // Verify previousBlockID + this._verifyPreviousBlockID(block); + + // Verify generatorAddress + await this._verifyGeneratorAddress(apiContext, block); + + // Verify BFT Properties + await this._verifyBFTProperties(apiContext, block); + + // verify Block signature + await this._verifyBlockSignature(apiContext, block); + } + + private async _verifyTimestamp(apiContext: APIContext, block: Block): Promise { + const blockSlotNumber = await this._validatorAPI.getSlotNumber( + apiContext, + block.header.timestamp, + ); + // Check that block is not from the future + const currentTimestamp = Math.floor(Date.now() / 1000); + const currentSlotNumber = await this._validatorAPI.getSlotNumber(apiContext, currentTimestamp); + if (blockSlotNumber > currentSlotNumber) { + throw new Error( + `Invalid timestamp ${ + block.header.timestamp + } of the block with id: ${block.header.id.toString('hex')}`, + ); + } + + // Check that block slot is strictly larger than the block slot of previousBlock + const { lastBlock } = this._chain; + const previousBlockSlotNumber = await this._validatorAPI.getSlotNumber( + apiContext, + lastBlock.header.timestamp, + ); + if (blockSlotNumber <= previousBlockSlotNumber) { + throw new Error( + `Invalid timestamp ${ + block.header.timestamp + } of the block with id: ${block.header.id.toString('hex')}`, + ); + } + } + + private _verifyPreviousBlockID(block: Block): void { + const { lastBlock } = this._chain; + + if (!block.header.previousBlockID.equals(lastBlock.header.id)) { + throw new Error( + `Invalid previousBlockID ${block.header.previousBlockID.toString( + 'hex', + )} of the block with id: ${block.header.id.toString('hex')}`, + ); + } + } + + private _verifyBlockHeight(block: Block): void { + const { lastBlock } = this._chain; + + if (block.header.height !== lastBlock.header.height + 1) { + throw new Error( + `Invalid height ${block.header.height} of the block with id: ${block.header.id.toString( + 'hex', + )}`, + ); + } + } + + private async _verifyGeneratorAddress(apiContext: APIContext, block: Block): Promise { + // Check that the generatorAddress has the correct length of 20 bytes + if (block.header.generatorAddress.length !== 20) { + throw new Error( + `Invalid length of generatorAddress ${block.header.generatorAddress.toString( + 'hex', + )} of the block with id: ${block.header.id.toString('hex')}`, + ); + } + const generatorAddress = await this._validatorAPI.getGeneratorAtTimestamp( + apiContext, + block.header.timestamp, + ); + // Check that the block generator is eligible to generate in this block slot. + if (!block.header.generatorAddress.equals(generatorAddress)) { + throw new Error( + `Generator with address ${block.header.generatorAddress.toString( + 'hex', + )} of the block with id: ${block.header.id.toString( + 'hex', + )} is ineligible to generate block for the current slot`, + ); + } + } + + private async _verifyBFTProperties(apiContext: APIContext, block: Block): Promise { + const bftParams = await this._bftAPI.getBFTHeights(apiContext); + + if (block.header.maxHeightPrevoted !== bftParams.maxHeightPrevoted) { + throw new Error( + `Invalid maxHeightPrevoted ${ + block.header.maxHeightPrevoted + } of the block with id: ${block.header.id.toString('hex')}`, + ); + } + const isContradictingHeaders = await this._bftAPI.isHeaderContradictingChain( + apiContext, + block.header, + ); + if (isContradictingHeaders) { + throw new Error( + `Contradicting headers for the block with id: ${block.header.id.toString('hex')}`, + ); + } + } + + private async _verifyBlockSignature(apiContext: APIContext, block: Block): Promise { + const { generatorKey } = await this._validatorAPI.getValidatorAccount( + apiContext, + block.header.generatorAddress, + ); + try { - await this._chain.verifyBlock(block); + block.header.validateSignature(generatorKey, this._chain.networkIdentifier); } catch (error) { - throw new ApplyPenaltyError((error as Error).message ?? 'Invalid block to be processed'); + throw new Error( + `Invalid signature ${block.header.signature.toString( + 'hex', + )} of the block with id: ${block.header.id.toString('hex')}`, + ); } } diff --git a/framework/src/node/consensus/types.ts b/framework/src/node/consensus/types.ts index 87e80bb583f..6e4bfaf9369 100644 --- a/framework/src/node/consensus/types.ts +++ b/framework/src/node/consensus/types.ts @@ -13,7 +13,8 @@ */ import { BFTHeights } from '../../modules/bft/types'; -import { ImmutableAPIContext } from '../state_machine'; +import { ValidatorKeys } from '../../modules/validators/types'; +import { BlockHeader, ImmutableAPIContext } from '../state_machine'; export interface BFTHeader { id: Buffer; @@ -35,9 +36,14 @@ export interface ValidatorAPI { getGeneratorAtTimestamp: (apiContext: ImmutableAPIContext, timestamp: number) => Promise; getSlotNumber: (apiContext: ImmutableAPIContext, timestamp: number) => Promise; getSlotTime: (apiContext: ImmutableAPIContext, slot: number) => Promise; + getValidatorAccount: (apiContext: ImmutableAPIContext, address: Buffer) => Promise; } export interface BFTAPI { getCurrentValidators: (apiContext: ImmutableAPIContext) => Promise; getBFTHeights: (apiClient: ImmutableAPIContext) => Promise; + isHeaderContradictingChain: ( + apiClient: ImmutableAPIContext, + header: BlockHeader, + ) => Promise; } diff --git a/framework/test/unit/node/consensus/consensus.spec.ts b/framework/test/unit/node/consensus/consensus.spec.ts index ad843a7f7bb..1dbfc85abd3 100644 --- a/framework/test/unit/node/consensus/consensus.spec.ts +++ b/framework/test/unit/node/consensus/consensus.spec.ts @@ -12,9 +12,12 @@ * Removal or modification of this copyright notice is prohibited. */ -import { Block, Chain } from '@liskhq/lisk-chain'; +import { Block, BlockAssets, Chain } from '@liskhq/lisk-chain'; import { KVStore } from '@liskhq/lisk-db'; import { codec } from '@liskhq/lisk-codec'; +import * as cryptography from '@liskhq/lisk-cryptography'; +import { when } from 'jest-when'; +import { Mnemonic } from '@liskhq/lisk-passphrase'; import { ApplyPenaltyError } from '../../../../src/errors'; import { CONSENSUS_EVENT_BLOCK_BROADCAST, @@ -33,9 +36,16 @@ import { import { Network } from '../../../../src/node/network'; import { StateMachine } from '../../../../src/node/state_machine/state_machine'; import { loggerMock } from '../../../../src/testing/mocks'; -import { createValidDefaultBlock, genesisBlock } from '../../../fixtures'; +import { + createFakeBlockHeader, + createValidDefaultBlock, + defaultNetworkIdentifier, + genesisBlock, +} from '../../../fixtures'; import * as forkchoice from '../../../../src/node/consensus/fork_choice/fork_choice_rule'; import { postBlockEventSchema } from '../../../../src/node/consensus/schema'; +import { APIContext } from '../../../../src/node/state_machine'; +import { createTransientAPIContext } from '../../../../src/testing'; describe('consensus', () => { const genesis = (genesisBlock() as unknown) as Block; @@ -79,8 +89,13 @@ describe('consensus', () => { getBFTHeights: jest .fn() .mockResolvedValue({ maxHeghgtPrevoted: 0, maxHeightPrecommitted: 0 }), + isHeaderContradictingChain: jest.fn(), + } as never; + validatorAPI = { + getGeneratorAtTimestamp: jest.fn(), + getValidatorAccount: jest.fn(), + getSlotNumber: jest.fn(), } as never; - validatorAPI = {} as never; consensus = new Consensus({ chain, network, @@ -254,7 +269,7 @@ describe('consensus', () => { .spyOn(forkchoice, 'forkChoice') .mockReturnValue(forkchoice.ForkStatus.DOUBLE_FORGING); jest.spyOn(consensus.events, 'emit'); - jest.spyOn(consensus, '_verify' as any); + jest.spyOn(consensus, '_verify' as any).mockResolvedValue(true); jest.spyOn(consensus, '_executeValidated' as any); await consensus.onBlockReceive(input, peerID); }); @@ -276,7 +291,7 @@ describe('consensus', () => { beforeEach(async () => { jest.spyOn(forkchoice, 'forkChoice').mockReturnValue(forkchoice.ForkStatus.TIE_BREAK); jest.spyOn(consensus.events, 'emit'); - jest.spyOn(consensus, '_verify' as any); + jest.spyOn(consensus, '_verify' as any).mockResolvedValue(true); jest.spyOn(consensus, '_executeValidated' as any).mockResolvedValue(undefined); jest.spyOn(consensus, 'finalizedHeight').mockReturnValue(0); await consensus.onBlockReceive(input, peerID); @@ -303,7 +318,7 @@ describe('consensus', () => { beforeEach(async () => { jest.spyOn(forkchoice, 'forkChoice').mockReturnValue(forkchoice.ForkStatus.TIE_BREAK); jest.spyOn(consensus.events, 'emit'); - jest.spyOn(consensus, '_verify' as any); + jest.spyOn(consensus, '_verify' as any).mockResolvedValue(true); jest .spyOn(consensus, '_executeValidated' as any) .mockRejectedValueOnce(new Error('invalid block')); @@ -338,7 +353,7 @@ describe('consensus', () => { .spyOn(forkchoice, 'forkChoice') .mockReturnValue(forkchoice.ForkStatus.DIFFERENT_CHAIN); jest.spyOn(consensus.events, 'emit'); - jest.spyOn(consensus, '_verify' as any); + jest.spyOn(consensus, '_verify' as any).mockResolvedValue(true); jest.spyOn(consensus, '_executeValidated' as any); jest.spyOn(consensus['_synchronizer'], 'run').mockResolvedValue(undefined); await consensus.onBlockReceive(input, peerID); @@ -447,7 +462,7 @@ describe('consensus', () => { beforeEach(async () => { jest.spyOn(forkchoice, 'forkChoice').mockReturnValue(forkchoice.ForkStatus.DISCARD); jest.spyOn(consensus.events, 'emit'); - jest.spyOn(consensus, '_verify' as any); + jest.spyOn(consensus, '_verify' as any).mockResolvedValue(true); jest.spyOn(consensus, '_executeValidated' as any).mockResolvedValue(undefined); await consensus.onBlockReceive(input, peerID); }); @@ -469,7 +484,7 @@ describe('consensus', () => { beforeEach(async () => { jest.spyOn(forkchoice, 'forkChoice').mockReturnValue(forkchoice.ForkStatus.VALID_BLOCK); jest.spyOn(consensus.events, 'emit'); - jest.spyOn(consensus, '_verify' as any); + jest.spyOn(consensus, '_verify' as any).mockResolvedValue(true); jest.spyOn(consensus, '_executeValidated' as any).mockResolvedValue(undefined); await consensus.onBlockReceive(input, peerID); }); @@ -488,9 +503,11 @@ describe('consensus', () => { describe('execute', () => { let block: Block; + let apiContext: APIContext; beforeEach(async () => { block = await createValidDefaultBlock({ header: { height: 2 } }); + apiContext = createTransientAPIContext({}); jest.spyOn(chain, 'saveBlock').mockResolvedValue(); jest.spyOn(consensus, 'finalizedHeight').mockReturnValue(0); @@ -501,6 +518,230 @@ describe('consensus', () => { await consensus.execute(block); }); + describe('block verification', () => { + describe('timestamp', () => { + it('should throw error when block timestamp is from future', async () => { + const invalidBlock = { ...block }; + const now = Date.now(); + + Date.now = jest.fn(() => now); + + (invalidBlock.header as any).timestamp = Math.floor((Date.now() + 10000) / 1000); + when(consensus['_validatorAPI'].getSlotNumber as any) + .calledWith(apiContext, (invalidBlock.header as any).timestamp) + .mockResolvedValue(10 as never) + .calledWith(apiContext, Math.floor(now / 1000)) + .mockResolvedValue(5 as never); + + await expect( + consensus['_verifyTimestamp'](apiContext, invalidBlock as any), + ).rejects.toThrow( + `Invalid timestamp ${ + invalidBlock.header.timestamp + } of the block with id: ${invalidBlock.header.id.toString('hex')}`, + ); + }); + + it('should throw error when block slot is less than previous block slot', async () => { + const invalidBlock = { ...block }; + const now = Date.now(); + + Date.now = jest.fn(() => now); + + (invalidBlock.header as any).timestamp = Math.floor(Date.now() / 1000); + when(consensus['_validatorAPI'].getSlotNumber as any) + .calledWith(apiContext, (invalidBlock.header as any).timestamp) + .mockResolvedValue(10 as never) + .calledWith(apiContext, Math.floor(now / 1000)) + .mockResolvedValue(10 as never); + + (consensus['_chain'].lastBlock.header as any).timestamp = Math.floor(Date.now() / 1000); + + await expect( + consensus['_verifyTimestamp'](apiContext, invalidBlock as any), + ).rejects.toThrow( + `Invalid timestamp ${ + invalidBlock.header.timestamp + } of the block with id: ${invalidBlock.header.id.toString('hex')}`, + ); + }); + + it('should be success when valid block timestamp', async () => { + const now = Date.now(); + + Date.now = jest.fn(() => now); + + (block.header as any).timestamp = Math.floor(Date.now() / 1000); + when(consensus['_validatorAPI'].getSlotNumber as any) + .calledWith(apiContext, (block.header as any).timestamp) + .mockResolvedValue(10 as never) + .calledWith(apiContext, Math.floor(now / 1000)) + .mockResolvedValue(10 as never); + + await expect( + consensus['_verifyTimestamp'](apiContext, block as any), + ).resolves.toBeUndefined(); + }); + }); + + describe('height', () => { + it('should throw error when block height is equal to last block height', () => { + const invalidBlock = { ...block }; + (invalidBlock.header as any).height = consensus['_chain'].lastBlock.header.height; + + expect(() => consensus['_verifyBlockHeight'](invalidBlock as any)).toThrow( + `Invalid height ${ + invalidBlock.header.height + } of the block with id: ${invalidBlock.header.id.toString('hex')}`, + ); + }); + it('should be success when block has [height===lastBlock.height+1]', () => { + expect(consensus['_verifyBlockHeight'](block as any)).toBeUndefined(); + }); + }); + + describe('previousBlockID', () => { + it('should throw error for invalid previousBlockID', () => { + const invalidBlock = { ...block }; + (invalidBlock.header as any).previousBlockID = cryptography.getRandomBytes(64); + + expect(() => consensus['_verifyPreviousBlockID'](block as any)).toThrow( + `Invalid previousBlockID ${invalidBlock.header.previousBlockID.toString( + 'hex', + )} of the block with id: ${invalidBlock.header.id.toString('hex')}`, + ); + }); + it('should be success when previousBlockID is equal to lastBlock ID', async () => { + const validBlock = await createValidDefaultBlock({ + header: { height: 2, previousBlockID: consensus['_chain'].lastBlock.header.id }, + }); + expect(consensus['_verifyPreviousBlockID'](validBlock as any)).toBeUndefined(); + }); + }); + + describe('generatorAddress', () => { + it('should throw error if [generatorAddress.lenght !== 20]', async () => { + const invalidBlock = { ...block }; + (invalidBlock.header as any).generatorAddress = cryptography.getRandomBytes(64); + await expect( + consensus['_verifyGeneratorAddress'](apiContext, invalidBlock as any), + ).rejects.toThrow( + `Invalid length of generatorAddress ${invalidBlock.header.generatorAddress.toString( + 'hex', + )} of the block with id: ${invalidBlock.header.id.toString('hex')}`, + ); + }); + + it('should throw error if generatorAddress has wrong block slot', async () => { + when(consensus['_validatorAPI'].getGeneratorAtTimestamp as never) + .calledWith(apiContext, (block.header as any).timestamp) + .mockResolvedValue(cryptography.getRandomBytes(20) as never); + + await expect( + consensus['_verifyGeneratorAddress'](apiContext, block as any), + ).rejects.toThrow( + `Generator with address ${block.header.generatorAddress.toString( + 'hex', + )} of the block with id: ${block.header.id.toString( + 'hex', + )} is ineligible to generate block for the current slot`, + ); + }); + + it('should be success if generatorAddress is valid and has right block slot', async () => { + when(consensus['_validatorAPI'].getGeneratorAtTimestamp as never) + .calledWith(apiContext, (block.header as any).timestamp) + .mockResolvedValue(block.header.generatorAddress as never); + + await expect( + consensus['_verifyGeneratorAddress'](apiContext, block as any), + ).resolves.toBeUndefined(); + }); + }); + + describe('bftProperties', () => { + it('should throw error for invalid maxHeightPrevoted', async () => { + when(consensus['_bftAPI'].getBFTHeights as never) + .calledWith(apiContext) + .mockResolvedValue({ maxHeightPrevoted: block.header.maxHeightPrevoted + 1 } as never); + + await expect(consensus['_verifyBFTProperties'](apiContext, block as any)).rejects.toThrow( + `Invalid maxHeightPrevoted ${ + block.header.maxHeightPrevoted + } of the block with id: ${block.header.id.toString('hex')}`, + ); + }); + + it('should throw error if the header is contradicting', async () => { + when(consensus['_bftAPI'].getBFTHeights as never) + .calledWith(apiContext) + .mockResolvedValue({ maxHeightPrevoted: block.header.maxHeightPrevoted } as never); + + when(consensus['_bftAPI'].isHeaderContradictingChain as never) + .calledWith(apiContext, block.header) + .mockResolvedValue(true as never); + + await expect(consensus['_verifyBFTProperties'](apiContext, block as any)).rejects.toThrow( + `Contradicting headers for the block with id: ${block.header.id.toString('hex')}`, + ); + }); + + it('should be success if maxHeightPrevoted is valid and header is not contradicting', async () => { + when(consensus['_bftAPI'].getBFTHeights as never) + .calledWith(apiContext) + .mockResolvedValue({ maxHeightPrevoted: block.header.maxHeightPrevoted } as never); + + when(consensus['_bftAPI'].isHeaderContradictingChain as never) + .calledWith(apiContext, block.header) + .mockResolvedValue(false as never); + + await expect( + consensus['_verifyBFTProperties'](apiContext, block as any), + ).resolves.toBeUndefined(); + }); + }); + + describe('signature', () => { + it('should throw error for invalid signature', async () => { + const generatorKey = cryptography.getRandomBytes(32); + + when(consensus['_validatorAPI'].getValidatorAccount as never) + .calledWith(apiContext, block.header.generatorAddress) + .mockResolvedValue({ generatorKey } as never); + + await expect( + consensus['_verifyBlockSignature'](apiContext, block as any), + ).rejects.toThrow( + `Invalid signature ${block.header.signature.toString( + 'hex', + )} of the block with id: ${block.header.id.toString('hex')}`, + ); + }); + + it('should be success when valid signature', async () => { + const passphrase = Mnemonic.generateMnemonic(); + const keyPair = cryptography.getPrivateAndPublicKeyFromPassphrase(passphrase); + + const blockHeader = createFakeBlockHeader(); + (blockHeader as any).generatorAddress = cryptography.getAddressFromPublicKey( + keyPair.publicKey, + ); + (consensus['_chain'] as any).networkIdentifier = defaultNetworkIdentifier; + + blockHeader.sign(consensus['_chain'].networkIdentifier, keyPair.privateKey); + const validBlock = new Block(blockHeader, [], new BlockAssets()); + + when(consensus['_validatorAPI'].getValidatorAccount as never) + .calledWith(apiContext, validBlock.header.generatorAddress) + .mockResolvedValue({ generatorKey: keyPair.publicKey } as never); + + await expect( + consensus['_verifyBlockSignature'](apiContext, validBlock as any), + ).resolves.toBeUndefined(); + }); + }); + }); + it('should verify block using state machine', () => { expect(stateMachine.verifyBlock).toHaveBeenCalledTimes(1); });