Allow light client to verify signatures at period boundary

As the sync committee signs the previous block, the situation arises at
every sync committee period boundary, that the new sync committee signs
a block in the previous sync committee period. The light client cannot
reliably detect this condition (e.g., assume that this is the case when
it is currently on the last slot of a sync committee period), because
the last couple slots of a sync committee period may not have a block.

For example, when receiving a `LightClientUpdate` that is constructed
as in the following illustration, it is unknown whether `sync_aggregate`
was signed by the current or next sync committee at `attested_header`.

```

        slot N           N + 1   |            N + 2   (slot not sent!)
                                 |
  +-----------------+     \ /    |     +----------------+
  | attested_header | <--- X ----|---- | sync_aggregate |
  +-----------------+     / \    |     +----------------+
                        missed   |
                                 |
                          sync committee
                          period boundary
```

This patch addresses this edge case by including the slot at which the
`sync_aggregate` was created into the `LightClientUpdate` object.

Note that the `signature_slot` cannot be trusted beyond the purpose of
signature verification, as it could be manipulated to any other slot
within the same sync committee period and fork version, without making
the `sync_aggregate` invalid.
This commit is contained in:
Etan Kissling 2022-04-26 22:32:25 +02:00
parent 83ac38c183
commit 5653649ca8
No known key found for this signature in database
GPG Key ID: B21DA824C5A3D03D
3 changed files with 102 additions and 30 deletions

View File

@ -52,7 +52,7 @@ uses sync committees introduced in [this beacon chain extension](./beacon-chain.
| Name | Value | Unit | Duration |
| - | - | - | - |
| `MIN_SYNC_COMMITTEE_PARTICIPANTS` | `1` | validators |
| `MIN_SYNC_COMMITTEE_PARTICIPANTS` | `1` | validators | |
| `UPDATE_TIMEOUT` | `SLOTS_PER_EPOCH * EPOCHS_PER_SYNC_COMMITTEE_PERIOD` | slots | ~27.3 hours |
## Containers
@ -73,6 +73,8 @@ class LightClientUpdate(Container):
sync_aggregate: SyncAggregate
# Fork version for the aggregate signature
fork_version: Version
# Slot at which the aggregate signature was created (untrusted)
signature_slot: Slot
```
### `LightClientStore`
@ -162,15 +164,16 @@ def validate_light_client_update(store: LightClientStore,
genesis_validators_root: Root) -> None:
# Verify update slot is larger than slot of current best finalized header
active_header = get_active_header(update)
assert current_slot >= active_header.slot > store.finalized_header.slot
assert current_slot >= update.signature_slot > active_header.slot > store.finalized_header.slot
# Verify update does not skip a sync committee period
finalized_period = compute_sync_committee_period(compute_epoch_at_slot(store.finalized_header.slot))
update_period = compute_sync_committee_period(compute_epoch_at_slot(active_header.slot))
assert update_period in (finalized_period, finalized_period + 1)
signature_period = compute_sync_committee_period(compute_epoch_at_slot(update.signature_slot))
assert signature_period in (finalized_period, finalized_period + 1)
# Verify that the `finalized_header`, if present, actually is the finalized header saved in the
# state of the `attested header`
# state of the `attested_header`
if not is_finality_update(update):
assert update.finality_branch == [Bytes32() for _ in range(floorlog2(FINALIZED_ROOT_INDEX))]
else:
@ -184,10 +187,8 @@ def validate_light_client_update(store: LightClientStore,
# Verify update next sync committee if the update period incremented
if update_period == finalized_period:
sync_committee = store.current_sync_committee
assert update.next_sync_committee_branch == [Bytes32() for _ in range(floorlog2(NEXT_SYNC_COMMITTEE_INDEX))]
else:
sync_committee = store.next_sync_committee
assert is_valid_merkle_branch(
leaf=hash_tree_root(update.next_sync_committee),
branch=update.next_sync_committee_branch,
@ -202,6 +203,10 @@ def validate_light_client_update(store: LightClientStore,
assert sum(sync_aggregate.sync_committee_bits) >= MIN_SYNC_COMMITTEE_PARTICIPANTS
# Verify sync committee aggregate signature
if signature_period == finalized_period:
sync_committee = store.current_sync_committee
else:
sync_committee = store.next_sync_committee
participant_pubkeys = [
pubkey for (bit, pubkey) in zip(sync_aggregate.sync_committee_bits, sync_committee.pubkeys)
if bit

View File

@ -39,8 +39,9 @@ def test_process_light_client_update_not_timeout(spec, state):
state_root=signed_block.message.state_root,
body_root=signed_block.message.body.hash_tree_root(),
)
# Sync committee signing the header
sync_aggregate = get_sync_aggregate(spec, state, block_header, block_root=None)
# Sync committee signing the block_header
sync_aggregate, fork_version, signature_slot = get_sync_aggregate(spec, state, block_header)
next_sync_committee_branch = [spec.Bytes32() for _ in range(spec.floorlog2(spec.NEXT_SYNC_COMMITTEE_INDEX))]
# Ensure that finality checkpoint is genesis
@ -56,12 +57,13 @@ def test_process_light_client_update_not_timeout(spec, state):
finalized_header=finality_header,
finality_branch=finality_branch,
sync_aggregate=sync_aggregate,
fork_version=state.fork.current_version,
fork_version=fork_version,
signature_slot=signature_slot,
)
pre_store = deepcopy(store)
spec.process_light_client_update(store, update, state.slot, state.genesis_validators_root)
spec.process_light_client_update(store, update, signature_slot, state.genesis_validators_root)
assert store.current_max_active_participants > 0
assert store.optimistic_header == update.attested_header
@ -69,6 +71,57 @@ def test_process_light_client_update_not_timeout(spec, state):
assert store.best_valid_update == update
@with_altair_and_later
@spec_state_test
@with_presets([MINIMAL], reason="too slow")
def test_process_light_client_update_at_period_boundary(spec, state):
store = initialize_light_client_store(spec, state)
# Forward to slot before next sync committee period so that next block is final one in period
next_slots(spec, state, spec.UPDATE_TIMEOUT - 2)
snapshot_period = spec.compute_sync_committee_period(spec.compute_epoch_at_slot(store.optimistic_header.slot))
update_period = spec.compute_sync_committee_period(spec.compute_epoch_at_slot(state.slot))
assert snapshot_period == update_period
block = build_empty_block_for_next_slot(spec, state)
signed_block = state_transition_and_sign_block(spec, state, block)
block_header = spec.BeaconBlockHeader(
slot=signed_block.message.slot,
proposer_index=signed_block.message.proposer_index,
parent_root=signed_block.message.parent_root,
state_root=signed_block.message.state_root,
body_root=signed_block.message.body.hash_tree_root(),
)
# Sync committee signing the block_header
sync_aggregate, fork_version, signature_slot = get_sync_aggregate(spec, state, block_header)
next_sync_committee_branch = [spec.Bytes32() for _ in range(spec.floorlog2(spec.NEXT_SYNC_COMMITTEE_INDEX))]
# Finality is unchanged
finality_header = spec.BeaconBlockHeader()
finality_branch = [spec.Bytes32() for _ in range(spec.floorlog2(spec.FINALIZED_ROOT_INDEX))]
update = spec.LightClientUpdate(
attested_header=block_header,
next_sync_committee=state.next_sync_committee,
next_sync_committee_branch=next_sync_committee_branch,
finalized_header=finality_header,
finality_branch=finality_branch,
sync_aggregate=sync_aggregate,
fork_version=fork_version,
signature_slot=signature_slot,
)
pre_store = deepcopy(store)
spec.process_light_client_update(store, update, signature_slot, state.genesis_validators_root)
assert store.current_max_active_participants > 0
assert store.optimistic_header == update.attested_header
assert store.best_valid_update == update
assert store.finalized_header == pre_store.finalized_header
@with_altair_and_later
@spec_state_test
@with_presets([MINIMAL], reason="too slow")
@ -91,9 +144,8 @@ def test_process_light_client_update_timeout(spec, state):
body_root=signed_block.message.body.hash_tree_root(),
)
# Sync committee signing the finalized_block_header
sync_aggregate = get_sync_aggregate(
spec, state, block_header, block_root=spec.Root(block_header.hash_tree_root()))
# Sync committee signing the block_header
sync_aggregate, fork_version, signature_slot = get_sync_aggregate(spec, state, block_header)
# Sync committee is updated
next_sync_committee_branch = build_proof(state.get_backing(), spec.NEXT_SYNC_COMMITTEE_INDEX)
@ -108,12 +160,13 @@ def test_process_light_client_update_timeout(spec, state):
finalized_header=finality_header,
finality_branch=finality_branch,
sync_aggregate=sync_aggregate,
fork_version=state.fork.current_version,
fork_version=fork_version,
signature_slot=signature_slot,
)
pre_store = deepcopy(store)
spec.process_light_client_update(store, update, state.slot, state.genesis_validators_root)
spec.process_light_client_update(store, update, signature_slot, state.genesis_validators_root)
assert store.current_max_active_participants > 0
assert store.optimistic_header == update.attested_header
@ -157,9 +210,8 @@ def test_process_light_client_update_finality_updated(spec, state):
body_root=block.body.hash_tree_root(),
)
# Sync committee signing the finalized_block_header
sync_aggregate = get_sync_aggregate(
spec, state, block_header, block_root=spec.Root(block_header.hash_tree_root()))
# Sync committee signing the block_header
sync_aggregate, fork_version, signature_slot = get_sync_aggregate(spec, state, block_header)
update = spec.LightClientUpdate(
attested_header=block_header,
@ -168,10 +220,11 @@ def test_process_light_client_update_finality_updated(spec, state):
finalized_header=finalized_block_header,
finality_branch=finality_branch,
sync_aggregate=sync_aggregate,
fork_version=state.fork.current_version,
fork_version=fork_version,
signature_slot=signature_slot,
)
spec.process_light_client_update(store, update, state.slot, state.genesis_validators_root)
spec.process_light_client_update(store, update, signature_slot, state.genesis_validators_root)
assert store.current_max_active_participants > 0
assert store.optimistic_header == update.attested_header

View File

@ -1,5 +1,9 @@
from eth2spec.test.helpers.state import (
transition_to,
)
from eth2spec.test.helpers.sync_committee import (
compute_aggregate_sync_committee_signature,
compute_committee_indices,
)
@ -15,21 +19,31 @@ def initialize_light_client_store(spec, state):
)
def get_sync_aggregate(spec, state, block_header, block_root=None, signature_slot=None):
def get_sync_aggregate(spec, state, block_header, signature_slot=None):
# By default, the sync committee signs the previous slot
if signature_slot is None:
signature_slot = block_header.slot
signature_slot = block_header.slot + 1
all_pubkeys = [v.pubkey for v in state.validators]
committee = [all_pubkeys.index(pubkey) for pubkey in state.current_sync_committee.pubkeys]
sync_committee_bits = [True] * len(committee)
# Ensure correct sync committee and fork version are selected
signature_state = state.copy()
transition_to(spec, signature_state, signature_slot)
# Fetch sync committee
committee_indices = compute_committee_indices(spec, signature_state)
committee_size = len(committee_indices)
# Compute sync aggregate
sync_committee_bits = [True] * committee_size
sync_committee_signature = compute_aggregate_sync_committee_signature(
spec,
state,
block_header.slot,
committee,
block_root=block_root,
signature_state,
signature_slot,
committee_indices,
block_root=spec.Root(block_header.hash_tree_root()),
)
return spec.SyncAggregate(
sync_aggregate = spec.SyncAggregate(
sync_committee_bits=sync_committee_bits,
sync_committee_signature=sync_committee_signature,
)
fork_version = signature_state.fork.current_version
return sync_aggregate, fork_version, signature_slot