Skip to content

Lane A slashing: accusation evidence isn't bound to the accused's own signature #1855

Description

@zahrajavar

Summary

Lane A accusation-quorum slashing (SlashingManager.proposeSlash /
proposeSlashByDkgParty) verifies that a threshold_m quorum of committee
members signed off on a shared dataHash, but never verifies that the
accused operator actually authored the proof bytes being punished. Under a
dishonest threshold_m majority, this lets a colluding quorum fabricate an
invalid proof, attribute it to an innocent operator, and get them slashed —
with nothing on-chain, or checkable by an external observer from public
chain data, able to distinguish that from a genuine accusation.

Background

For a proof-type fault (C0–C6), the off-chain AccusationManager protocol
collects threshold_m signed votes and submits them on-chain as
attestation evidence:
proof = abi.encode(proofType, voters[], dataHashes[], evidence, issuedAt, deadline, signatures[])

SlashingEvidenceLib.verifyAttestationEvidence (packages/interfold-contracts/contracts/lib/SlashingEvidenceLib.sol:43-96)
checks:

  • keccak256(evidence) == dataHashes[0]
  • each voter's ECDSA signature recovers to an address that is an active
    committee member
  • voters.length >= threshold_m

It never checks a signature from the accused over evidence. evidence
itself is just abi.encode(proof.data, proof.public_signals) — the raw ZK
proof and public signals, with no binding to who produced them
(crates/slashing/src/accusation_voting/transitions/initiate_accusation.rs:44-53,
carried through unmodified by encode_attestation_evidence in
crates/evm/src/slashing/evidence.rs:26-59).

Because evidence is public calldata, anyone can independently re-run the
ZK verifier on it and confirm it's mathematically invalid — that half of
the claim is trustlessly checkable. But nothing ties those specific bytes
to the accused's own key. The only thing standing between an innocent
operator and a slash is threshold_m committee members choosing to sign
the same (possibly fabricated) dataHash.

agent/INVARIANTS.md:170-171 documents this as accepted design —
"Lane A is attestation-based (ECDSA per voter), not on-chain ZK
re-verification"
— but doesn't call out that authorship attribution is
also unauthenticated, which is the actual gap.

The missing piece already exists in the codebase

Every proof a node broadcasts is wrapped as SignedProofPayload { payload, signature } (crates/events/src/interfold_event/signed_proof.rs:179-185),
where signature is the accused's own ECDSA signature over
(chainId, e3Id, proofType, keccak256(proof.data), keccak256(public_signals)).
The type's doc comment even states the design intent:

"If the proof later fails verification, the signed bundle is
self-authenticating evidence of fault: the signature proves authorship
and the proof bytes prove invalidity."

This signature is already used, twice, off-chain:

  1. Every accuser calls SignedProofPayload::recover_address() locally
    before raising an accusation — ProofVerificationFailed.accused_address
    is set to the recovered signer
    (crates/zk-prover/src/proof_verification/handlers.rs:151).
  2. For C3a/C3b, it's forwarded in the P2P accusation gossip
    (ProofFailureAccusation.signed_payload) so peer committee members can
    also recover_address() before voting
    (crates/events/src/interfold_event/proof_failure_accusation.rs:41-43).

It is dropped before the evidence reaches the chain. encode_attestation_evidence
only ABI-encodes proof.data + public_signals
(crates/evm/src/slashing/evidence.rs:26-59); the accused's signature
field never makes it into the evidence bytes submitted to
SlashingManager.proposeSlash.

Proposed fix

  1. Include the accused's SignedProofPayload.signature in the on-chain
    evidence encoding (alongside proof.data / public_signals).
  2. In SlashingEvidenceLib.verifyAttestationEvidence, ecrecover that
    signature against the same digest the accused originally signed
    (ProofPayload::digest()'s Solidity-side reconstruction:
    keccak256(abi.encode(PROOF_PAYLOAD_TYPEHASH, chainId, e3Id, proofType, keccak256(zkProof), keccak256(publicSignals)))) and require(recovered == operator).
  3. With that check in place, the voter-quorum requirement can, in
    principle, be relaxed to a single submitter for proof-based faults —
    the evidence becomes self-authenticating (accused's signature proves
    authorship, the ZK math proves invalidity), so threshold_m honest
    votes are no longer load-bearing for correctness, only for whatever
    liveness/spam-resistance property the quorum requirement is also meant
    to provide. That's a separate design decision and out of scope for this
    fix, but worth flagging since it changes the trust model materially.

Impact if unfixed

A colluding threshold_m majority can slash (ticket + license penalties,
optional ban, committee expulsion) any innocent operator by fabricating an
invalid proof and jointly attesting to its hash. No on-chain check, and no
external observer working from public chain data alone, can currently
distinguish this from a legitimate accusation.

Scope note

This affects Lane A (attestation-based) slashing for proof-type faults
(C0–C6). It does not affect: liveness/timeout failures (markE3Failed,
deadline-based, no accusation involved), or Lane B (evidence-based,
SLASHER_ROLE-gated, already delayed-with-appeal by design). Aggregation
proofs (C5/C7) don't currently have a peer-accusation path wired at all
(separate gap, not covered here).

Metadata

Metadata

Labels

securityRelevant to security

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions