Skip to content

Bind proof public inputs to appropriate data onchain #1525

Description

@cedoor

Follow-up from #1516 (refactor: new proof aggregation).

The proof public inputs in the new aggregation flow need to be bound to the appropriate onchain data to guarantee that proofs are verified against the correct on-chain state.

Context

publishCommittee (CiphernodeRegistryOwnable.sol:192) accepts a caller-supplied address[] nodes and only validates its length against the stored committee:

require(nodes.length == c.topNodes.length, "Node count mismatch");

It never checks that nodes[i] == c.topNodes[i]. The on-chain committee members are already stored as c.topNodes from finalizeCommittee, but they are never compared to the submitted array. A malicious aggregator could pass the correct proof with an entirely different set of addresses in nodes and the contract would accept it.

The second binding gap is deeper: the aggregated proof's internal party_ids public inputs are field element integers (0..H-1) representing positional committee slots — Ethereum addresses appear nowhere in the ZK circuits. There is no cryptographic link between what the proof internally calls "party 0, party 1, …" and the on-chain addresses in c.topNodes. The contract emits:

emit CommitteePublished(e3Id, nodes, publicKey, pkCommitment, proof);

Since nodes is unvalidated, the emitted event — which downstream tooling and slashing attribution rely on — can log whichever addresses the aggregator chose to pass.

Proposed fix

Step 1 — Contract side (immediate, no circuit change needed)

Enforce identity between the submitted nodes and the on-chain topNodes in publishCommittee:

require(nodes.length == c.topNodes.length, "Node count mismatch");
for (uint256 i = 0; i < nodes.length; i++) {
    require(nodes[i] == c.topNodes[i], "Node address mismatch");
}

Or simplify by dropping the nodes parameter entirely and reading from c.topNodes directly, since the data is already on-chain:

function publishCommittee(
    uint256 e3Id,
    bytes calldata publicKey,
    bytes32 pkCommitment,
    bytes calldata proof
) external {
    // ...
    emit CommitteePublished(e3Id, c.topNodes, publicKey, pkCommitment, proof);
}

This closes the immediate slashing-attribution vector with a one-line change.

Step 2 — Circuit binding (stronger, closes the address↔party_id gap)

To cryptographically bind the proof's internal party_ids to on-chain committee addresses, include a commitment to the committee address list as a public input to dkg_aggregator. The contract would then verify:

bytes32 committeeHash = keccak256(abi.encodePacked(c.topNodes));
require(bytes32(publicInputs[COMMITTEE_HASH_IDX]) == committeeHash, "Committee mismatch");

This requires the dkg_aggregator circuit to accept and expose a commitment to the sorted node address list — a larger change, but the only way to make the proof itself attest to which real-world parties participated.

Step 1 is a low-risk immediate fix. Step 2 is the complete solution.

Metadata

Metadata

Assignees

No one assigned

    Labels

    refactoringimproving a software's internal structure without changing its external behavior or functionalitysecurityRelevant to security

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions