fix(gov-rotate): clear stale approvals on re-stage (multisig can commit an unsigned member set)#8015
Conversation
…commit an unsigned member set
/gov/rotate/approve verifies an Ed25519 signature over the canonical
message ROTATE|{epoch}|{threshold}|sha256(members_json), so each approval
authorizes exactly one (threshold, member-set) proposal. But gov_rotate_stage
uses INSERT OR REPLACE for the same epoch_effective and deletes gov_rotation +
gov_rotation_members WITHOUT deleting gov_rotation_approvals, and
gov_rotate_commit only does SELECT COUNT(*) over the approvals table -- it
never re-verifies the stored signatures against the current members_json.
Result: stage set A -> signer approves A -> re-stage the SAME epoch with a
different set B -> commit counts A's signature toward B and commits a member
set nobody ever signed. This defeats the rotation multisig, whose whole purpose
is that a single compromised admin key cannot unilaterally rotate the governance
signer set (stage is admin-gated; commit is not).
Fix: (re)staging redefines the signing message, so drop any approvals for that
epoch when staging. A one-line DELETE; commit now sees 0 approvals for the new
set and returns insufficient_approvals until it is signed afresh.
Test: node/tests/test_gov_rotate_restage_stale_approvals.py drives the real
Flask endpoints -- control (stage->approve->commit) still succeeds; the
re-stage-with-different-members exploit must NOT commit. Fails on current main
(commit returns 200 with the stale approval), passes with the fix; mutation
(revert the DELETE) fails again. Related governance suites stay green (19 passed).
|
Thank you for this run of security work, @Vyacheslav-Tomashevskiy — it's genuinely excellent. 205 RTC paid for 13 accepted findings: #8007, #8010, #8011, #8012, #8013, #8014, #8015, #7977, #7978, #7979 and the three vintage-x86 fixes.
The high-severity ones (reward-weight binding #8013, fund-exit freeze #8014, multisig re-stage #8015, airdrop Sybil #8010, bcos cert overwrite #8011) are exactly the kind of consensus-integrity work we most want. We're reviewing each fix for merge/hardening on our side — payment reflects the accepted finding, independent of how we land the final patch. Please keep them coming. — Sophia, Elyan Labs |
Bug: governance-rotation multisig approvals aren't bound to the staged member set
/gov/rotate/approveverifies an Ed25519 signature over the canonical messageROTATE|{epoch}|{threshold}|sha256(members_json), so each approval authorizes exactly one(threshold, member-set)proposal.But
gov_rotate_stagere-stages in place —INSERT OR REPLACE INTO gov_rotation_proposalsfor the sameepoch_effective, thenDELETE FROM gov_rotation+DELETE FROM gov_rotation_members, but notgov_rotation_approvals. Andgov_rotate_commitonly doesSELECT COUNT(*) FROM gov_rotation_approvals WHERE epoch_effective=?— it never re-verifies the stored signatures against the currentmembers_json.So a signature that only ever signed member-set A is counted toward committing member-set B. This defeats the rotation multisig, whose whole purpose is that a single compromised admin key cannot unilaterally rotate the governance signer set (
stageis admin-gated;commitis not).Exploit trace (reproduced against the real endpoints)
[signer1], threshold 1 → messageROTATE|E|1|sha256(A)./gov/rotate/approve→approvals:1, ready:true.[signer2]→ signing message changes; the approval row for A is untouched./gov/rotate/commit→{"committed":1,"ok":true,"approvals":1}— set B committed, though nobody ever signed set B.Fix
(Re)staging redefines the canonical signing message, so drop any approvals for that epoch when staging:
c.execute("DELETE FROM gov_rotation WHERE epoch_effective=?", (epoch,)) c.execute("DELETE FROM gov_rotation_members WHERE epoch_effective=?", (epoch,)) + c.execute("DELETE FROM gov_rotation_approvals WHERE epoch_effective=?", (epoch,))Commit then sees 0 approvals for the new set and returns
insufficient_approvalsuntil it is signed afresh.Test
node/tests/test_gov_rotate_restage_stale_approvals.pydrives the real Flask endpoints viatest_client:stage → approve → commit(no re-stage) still succeeds (fix doesn't break legitimate rotations).insufficient_approvals.Results: fails on current
main(commit returns 200committed:1with the stale approval); both pass with the fix; mutation (revert theDELETE) fails again. Related governance suites stay green (propose_nonce_replay,vote_race,rip0202_governance_params— 19 passed).Distinct from the
/governance/votequorum path — this is thegov_rotationstaged-multisig subsystem (stage/approve/commit).