ethereum / consensus-specs

Ethereum Proof-of-Stake Consensus Specifications
Creative Commons Zero v1.0 Universal
3.51k stars 950 forks source link

Sync committee slashing #3321

Open etan-status opened 1 year ago

etan-status commented 1 year ago

A dishonest supermajority of sync committee members is able to convince applications relying on Ethereum's light client sync protocol to assume a non-canonical finalized header, and to potentially take over the sync authority for future SyncCommitteePeriod. By signing a malicious beacon block root, a malicious (but valid!) LightClientUpdate message can be formed and subsequently used to, for example, exploit a trust-minimized bridge contract based on the light client sync protocol.

While honest nodes only propagate canonical LightClientUpdate messages, there is currently no disincentive against signing malicious beacon block roots. In fact, because there is no downside, it could even be considered a form of MEV to exploit bridges on top of performing all honest validator duties. See also: https://prestwich.substack.com/p/altair

Note that even if the entire sync committee could be fully slashed all the way down to 0 ETH, that this would still cap the security level to SYNC_COMMITTEE_SIZE * MAX_EFFECTIVE_BALANCE = 512 * 32 ETH = 16384 ETH ~= 32 million USD @ 2k USD / ETH. Bridges that secure more funds than this security level may combine the light client sync protocol with other established methods such as a multisig, or may want to require posting additional collateral to be eligible for updating the bridge. These methods are out of scope for this issue.

Goals

Slashing definition

An additional type of slashing should be introduced to deter against signing non-canonical beacon block roots as a sync committee member. As is the case with ProposerSlashing and AttesterSlashing, only malicious behaviour should be slashable. This includes simultaneous contradictory participation across multiple chain branches, but a validator that is simply tricked into syncing to an incorrect checkpoint should not be slashable even though it is participating on a non-canonical chain. Note that a slashing must be verifiable even without access to history, e.g., by a checkpoint synced beacon node.

Reference implementation

Python + Pytests + reference implementation for one of the consensus clients.

Slashing protection

The slashing protection database format should be extended to guarantee that validators can never be slashed regardless of chain syncing activity. For example, if a node operator deletes their chain data and syncs to a different branch, that should never be slashable as long as the slashing protection database is in place. The slashing protection interchange format needs to be updated accordingly.

Web3signer / Validator client APIs

APIs interacting with remote validators need to be extended to pass along information relevant to slashing protection. For example, a signer may need to know what the finalized checkpoint is at the signed block's post-state, before performing the signature operation.

Beacon APIs

An API to submit / observe sync committee slashing evidence needs to be added, to be used by slashers for getting evidence included into blocks. Likewise, a libp2p mesh is necessary to allow exchanging slashing evidence.

Slasher

A slasher component should be developed that observes interactions with popular bridges and automatically propagates slashable evidence, and alerts operators about unexpected data.

Reporting

Progress should be reported and discussed in Eth R&D Discord #light-clients.

Prior work

A coarse slashing proposal can be found here: https://github.com/metacraft-labs/DendrETH/tree/main/docs/long-range-syncing#sync_committee_slashing-proposal - note that this is not feasible due to historic access requirements, and due to slashability even for syncing to an incorrect head.

Existing ideas (untested draft!)

Note: This still allows having contradictions between attestations/proposals and sync committee messages. This also, by design, allows a validator to not participate at all in honest sync committee messages but solely participate in dishonest sync committee messages.

Name Value
BLOCK_STATE_ROOT_INDEX get_generalized_index(BeaconBlock, 'state_root') (= 11)
STATE_BLOCK_ROOTS_INDEX get_generalized_index(BeaconState, 'block_roots') (= 37)
STATE_HISTORICAL_ROOTS_INDEX get_generalized_index(BeaconState, 'historical_roots') (= 39)
HISTORICAL_BATCH_BLOCK_ROOTS_INDEX get_generalized_index(HistoricalBatch, 'block_roots') (= 2)
MAX_SYNC_COMMITTEE_SLASHINGS 2**0 (= 1)
class SyncCommitteeSlashingEvidence(Container):
    attested_header: BeaconBlockHeader
    next_sync_committee: SyncCommittee
    next_sync_committee_branch: Vector[Root, floorlog2(NEXT_SYNC_COMMITTEE_INDEX)]
    finalized_header: BeaconBlockHeader
    finality_branch: Vector[Root, floorlog2(FINALIZED_ROOT_INDEX)]
    sync_aggregate: SyncAggregate
    signature_slot: Slot
    sync_committee_pubkeys: Vector[BLSPubkey, SYNC_COMMITTEE_SIZE]
    actual_finalized_block_root: Root
    actual_finalized_branch: List[Root, (
        floorlog2(BLOCK_STATE_ROOT_INDEX)
        + floorlog2(STATE_HISTORICAL_ROOTS_INDEX)
        + 1 + floorlog2(HISTORICAL_ROOTS_LIMIT)
        + floorlog2(HISTORICAL_BATCH_BLOCK_ROOTS_INDEX)
        + 1 + floorlog2(SLOTS_PER_HISTORICAL_ROOT))]

class SyncCommitteeSlashing(Container):
    slashable_validators: List[ValidatorIndex, SYNC_COMMITTEE_SIZE]
    evidence_1: SyncCommitteeSlashingEvidence
    evidence_2: SyncCommitteeSlashingEvidence
    recent_finalized_block_root: Root
    recent_finalized_slot: Slot

def sync_committee_slashing_evidence_has_sync_committee(evidence: SyncCommitteeSlashingEvidence) -> bool:
    return evidence.next_sync_committee_branch != [Root() for _ in range(floorlog2(NEXT_SYNC_COMMITTEE_INDEX))]

def sync_committee_slashing_evidence_has_finality(evidence: SyncCommitteeSlashingEvidence) -> bool:
    return evidence.finality_branch != [Root() for _ in range(floorlog2(FINALIZED_ROOT_INDEX))]

def is_valid_sync_committee_slashing_evidence(evidence: SyncCommitteeSlashingEvidence,
                                              recent_finalized_block_root: Root,
                                              recent_finalized_slot: Slot,
                                              genesis_validators_root: Root) -> bool:
    # Verify sync committee has sufficient participants
    sync_aggregate = evidence.sync_aggregate
    if sum(sync_aggregate.sync_committee_bits) < MIN_SYNC_COMMITTEE_PARTICIPANTS:
        return False

    # Verify that the `finality_branch`, if present, confirms `finalized_header`
    # to match the finalized checkpoint root saved in the state of `attested_header`.
    # Note that the genesis finalized checkpoint root is represented as a zero hash.
    if not sync_committee_slashing_evidence_has_finality(evidence):
        if evidence.actual_finalized_block_root != Root():
            return False
        if evidence.finalized_header != BeaconBlockHeader():
            return False
    else:
        if evidence.finalized_header.slot == GENESIS_SLOT:
            if evidence.actual_finalized_block_root != Root():
                return False
            if evidence.finalized_header != BeaconBlockHeader():
                return False
            finalized_root = Root()
        else:
            finalized_root = hash_tree_root(evidence.finalized_header)
        if not is_valid_merkle_branch(
            leaf=finalized_root,
            branch=evidence.finality_branch,
            depth=floorlog2(FINALIZED_ROOT_INDEX),
            index=get_subtree_index(FINALIZED_ROOT_INDEX),
            root=evidence.attested_header.state_root,
        ):
            return False

    # Verify that the `next_sync_committee`, if present, actually is the next sync committee saved in the
    # state of the `attested_header`
    if not sync_committee_slashing_evidence_has_sync_committee(evidence):
        if evidence.next_sync_committee != SyncCommittee():
            return False
    else:
        if not is_valid_merkle_branch(
            leaf=hash_tree_root(evidence.next_sync_committee),
            branch=evidence.next_sync_committee_branch,
            depth=floorlog2(NEXT_SYNC_COMMITTEE_INDEX),
            index=get_subtree_index(NEXT_SYNC_COMMITTEE_INDEX),
            root=evidence.attested_header.state_root,
        ):
            return False

    # Verify that the `actual_finalized_block_root`, if present, is confirmed by `actual_finalized_branch`
    # to be the block root at slot `finalized_header.slot` relative to `recent_finalized_block_root`
    if recent_finalized_block_root == Root():
        if evidence.actual_finalized_block_root != Root():
            return False
    if evidence.actual_finalized_block_root == Root():
        if len(evidence.actual_finalized_branch) != 0:
            return False
    else:
        finalized_slot = evidence.finalized_header.slot
        if recent_finalized_slot < finalized_slot:
            return False
        distance = recent_finalized_slot - finalized_slot
        if distance == 0:
            gindex = GeneralizedIndex(1)
        else:
            gindex = BLOCK_STATE_ROOT_INDEX
            if distance <= SLOTS_PER_HISTORICAL_ROOT:
                gindex = (gindex << floorlog2(STATE_BLOCK_ROOTS_INDEX)) + STATE_BLOCK_ROOTS_INDEX
            else:
                gindex = (gindex << floorlog2(STATE_HISTORICAL_ROOTS_INDEX)) + STATE_HISTORICAL_ROOTS_INDEX
                gindex = (gindex << uint64(1)) + 0  # `mix_in_length`
                historical_batch_index = finalized_slot // SLOTS_PER_HISTORICAL_ROOT
                gindex = (gindex << floorlog2(HISTORICAL_ROOTS_LIMIT)) + historical_batch_index
                gindex = (gindex << floorlog2(HISTORICAL_BATCH_BLOCK_ROOTS_INDEX)) + HISTORICAL_BATCH_BLOCK_ROOTS_INDEX
            gindex = (gindex << uint64(1)) + 0  # `mix_in_length`
            block_root_index = finalized_slot % SLOTS_PER_HISTORICAL_ROOT
            gindex = (gindex << floorlog2(SLOTS_PER_HISTORICAL_ROOT)) + block_root_index
        if len(evidence.actual_finalized_branch) != floorlog2(gindex):
            return False
        if not is_valid_merkle_branch(
            leaf=evidence.actual_finalized_block_root,
            branch=evidence.actual_finalized_branch,
            depth=floorlog2(gindex),
            index=get_subtree_index(gindex),
            root=recent_finalized_block_root,
        ):
            return False

    # Verify sync committee aggregate signature
    sync_committee_pubkeys = evidence.sync_committee_pubkeys
    participant_pubkeys = [
        pubkey for (bit, pubkey) in zip(sync_aggregate.sync_committee_bits, sync_committee_pubkeys)
        if bit
    ]
    fork_version = compute_fork_version(compute_epoch_at_slot(evidence.signature_slot))
    domain = compute_domain(DOMAIN_SYNC_COMMITTEE, fork_version, genesis_validators_root)
    signing_root = compute_signing_root(evidence.attested_header, domain)
    return bls.FastAggregateVerify(participant_pubkeys, signing_root, sync_aggregate.sync_committee_signature)

def process_sync_committee_slashing(state: BeaconState, sync_committee_slashing: SyncCommitteeSlashing) -> None:
    is_slashable = False

    # Check that evidence is ordered descending by `attested_header.slot` and is not from the future
    evidence_1 = sync_committee_slashing.evidence_1
    evidence_2 = sync_committee_slashing.evidence_2
    assert state.slot >= evidence_1.signature_slot > evidence_1.attested_header.slot >= evidence_1.finalized_header.slot
    assert state.slot >= evidence_2.signature_slot > evidence_2.attested_header.slot >= evidence_2.finalized_header.slot
    assert evidence_1.attested_header.slot >= evidence_2.attested_header.slot

    # Only conflicting data among the current and previous sync committee period is slashable;
    # on new periods, the sync committee initially signs blocks in a previous sync committee period.
    # This allows a validator synced to a malicious checkpoint to contribute again in a future period
    evidence_1_attested_period = compute_sync_committee_period_at_slot(evidence_1.attested_header.slot)
    evidence_2_attested_period = compute_sync_committee_period_at_slot(evidence_2.attested_header.slot)
    assert evidence_1_attested_period <= evidence_2_attested_period + 1

    # It is not allowed to sign conflicting `attested_header` for a given slot
    if evidence_1.attested_header.slot == evidence_2.attested_header.slot:
        if evidence_1.attested_header != evidence_2.attested_header:
            is_slashable = True

    # It is not allowed to sign conflicting finalized `next_sync_committee`
    evidence_1_finalized_period = compute_sync_committee_period_at_slot(evidence_1.finalized_header.slot)
    evidence_2_finalized_period = compute_sync_committee_period_at_slot(evidence_2.finalized_header.slot)
    if (
        evidence_1_attested_period == evidence_2_attested_period
        and evidence_1_finalized_period == evidence_1_attested_period
        and evidence_2_finalized_period == evidence_2_attested_period
        and sync_committee_slashing_evidence_has_finality(evidence_1)
        and sync_committee_slashing_evidence_has_finality(evidence_2)
        and sync_committee_slashing_evidence_has_sync_committee(evidence_1)
        and sync_committee_slashing_evidence_has_sync_committee(evidence_2)
    ):
        if evidence_1.next_sync_committee != evidence_2.next_sync_committee:
            is_slashable = True

    # It is not allowed to sign a non-linear finalized history
    recent_finalized_slot = sync_committee_slashing.recent_finalized_slot
    recent_finalized_block_root = sync_committee_slashing.recent_finalized_block_root
    if (
        not sync_committee_slashing_evidence_has_finality(evidence_1)
        or not sync_committee_slashing_evidence_has_finality(evidence_2)
    ):
        assert recent_finalized_block_root == Root()
    if recent_finalized_block_root == Root():
        assert recent_finalized_slot == 0
    else:
        # Merkle proofs may be included to indicate that `finalized_header` does not match
        # the `actual_finalized_block_root` relative to a given `recent_finalized_block_root`.
        # The finalized history is linear. Therefore, a mismatch indicates signing on an unrelated chain.
        # Note that it is not slashable to sign solely an alternate history, as long as it is consistent.
        # This allows a validator synced to a malicious checkpoint to contribute again in a future period
        linear_1 = (evidence_1.actual_finalized_block_root == hash_tree_root(evidence_1.finalized_header))
        linear_2 = (evidence_2.actual_finalized_block_root == hash_tree_root(evidence_2.finalized_header))
        assert not linear_1 or not linear_2
        assert linear_1 or linear_2  # Do not slash on signing solely an alternate history

        # `actual_finalized_branch` may be rooted in the provided `finalized_header` with highest slot
        rooted_in_evidence_1 = (
            evidence_1.finalized_header.slot >= evidence_2.finalized_header.slot
            and recent_finalized_slot == evidence_1.finalized_header.slot
            and recent_finalized_block_root == evidence_1.actual_finalized_block_root and linear_1
        )
        rooted_in_evidence_2 = (
            evidence_2.finalized_header.slot >= evidence_1.finalized_header.slot
            and recent_finalized_slot == evidence_2.finalized_header.slot
            and recent_finalized_block_root == evidence_2.actual_finalized_block_root and linear_2
        )

        # Alternatively, if evidence about non-linearity cannot be obtained directly from an attack,
        # it can be proven that one of the `finalized_header` is part of the canonical finalized chain
        # that our beacon node is synced to, while the other `finalized_header` is unrelated.
        rooted_in_canonical = (
            recent_finalized_slot < state.slot <= recent_finalized_slot + SLOTS_PER_HISTORICAL_ROOT
            and recent_finalized_slot <= compute_start_slot_at_epoch(state.finalized_checkpoint.epoch)
            and recent_finalized_block_root == state.state_roots[recent_finalized_slot % SLOTS_PER_HISTORICAL_ROOT]
        )
        assert rooted_in_evidence_1 or rooted_in_evidence_2 or rooted_in_canonical
        is_slashable = True

    assert is_slashable

    # Check that slashable validators are sorted, known, and participated in both signatures
    will_slash_any = False
    sync_aggregate_1 = evidence_1.sync_aggregate
    sync_aggregate_2 = evidence_2.sync_aggregate
    sync_committee_pubkeys_1 = evidence_1.sync_committee_pubkeys
    sync_committee_pubkeys_2 = evidence_2.sync_committee_pubkeys
    participant_pubkeys_1 = [
        pubkey for (bit, pubkey) in zip(sync_aggregate_1.sync_committee_bits, sync_committee_pubkeys_1)
        if bit
    ]
    participant_pubkeys_2 = [
        pubkey for (bit, pubkey) in zip(sync_aggregate_2.sync_committee_bits, sync_committee_pubkeys_2)
        if bit
    ]
    slashable_validators = sync_committee_slashing.slashable_validators
    num_validators = len(state.validators)
    for i, index in enumerate(slashable_validators):
        assert (
            index < num_validators
            and (i == 0 or index > slashable_validators[i - 1])
        )
        assert state.validators[index].pubkey in participant_pubkeys_1
        assert state.validators[index].pubkey in participant_pubkeys_2
        if is_slashable_validator(state.validators[index], get_current_epoch(state)):
            will_slash_any = True
    assert will_slash_any

    # Validate evidence, including signatures
    assert is_valid_sync_committee_slashing_evidence(
        evidence_1,
        recent_finalized_block_root,
        recent_finalized_slot,
        state.genesis_validator_root,
    )
    assert is_valid_sync_committee_slashing_evidence(
        evidence_2,
        recent_finalized_block_root,
        recent_finalized_slot,
        state.genesis_validator_root,
    )

    # Perform slashing
    for index in slashable_validators:
        if is_slashable_validator(state.validators[index], get_current_epoch(state)):
            slash_validator(state, index)
dapplion commented 1 year ago

Noting that existing slashing is proportional to the amount of stake performing the attack. Should sync committee slashing be part of that multiplier? Say if 100% of the sync committee participates in the attack, should the penalties by much higher than if only 1 participant does?

etan-status commented 1 year ago

It can be a bit tricky to count, as the sync committees may be different across the various branches (canonical vs attacker), e.g., due to non-finality for prolonged time, or due to attacker mixing malicious pubkeys with real pubkeys to try and appear more legitimate. We also want to slash, when we detect a pubkey being used to exploit two bridges on other branches, even when they are not signing the canonical chain.

Furthermore, slashing evidence may not provide us with all of the participating pubkeys, it may be just a subset of keys, further complicating a correct multiplier.

Finally, slashing evidence could be submitted after the exploit already happened. e.g., manual analysis, then discover that this was a sync committee based attack 1-2 days later, compute the evidence, submit it, and hope that the attackers couldn't exit faster than that.

Overall, it makes sense though, to scale the penalty accordingly.

Having said that, at this time I also wouldn't rule out more sophisticated reward/penalty schemes specifically aimed at sync committee members.

For example, one could require that if you attested in the last hour before knowing that you will be part of next_sync_committee, that you are required to also participate on the canonical chain in that next_sync_committee. If you then only participate in a non-canonical chain (after participating on the canonical chain a day ago), that may also be a slashing condition.

etan-status commented 1 year ago

Also, another thing to keep in mind re slashing amounts, is, that, slashings won't affect current/next_sync_committee but can only be accommodated for the future. Sync committees are locked in place a day before they become active.